Skip to content

Design quality ​

A schema-valid document can still be hard to read: text may overflow, a slide may contain too much prose, or a table may be wider than the page. The design quality layer checks those problems after structural validation has succeeded.

Quality findings are advisory by default. Each finding explains the problem, points to the authored JSON path and records how certain the check is. A policy can promote, suppress or use findings to block CI and generation.

Every surface runs the same analysis. This guide uses the CLI because it is the shortest thing to show, but the playground is the fastest way to see findings on a document you are writing: it analyses as you type, and applies the suggested fixes with a click.

Start with the CLI ​

Run the default rules without blocking:

bash
jto pptx validate deck.json
jto docx validate report.json

Make warnings and errors fail the command:

bash
jto pptx validate deck.json --quality-gate warning

Use a shipped profile by putting its ID in a JSON file:

json
{
  "id": "executive-presentation",
  "formats": ["pptx"]
}
bash
jto pptx validate deck.json --quality-profile executive-presentation.json

The same options work with generate. Generation stops before rendering when the selected quality gate is not satisfied.

How analysis works ​

The format core first resolves the authored document into a prepared model. It applies the information the renderer will use — themes, defaults, blocks, registered code components, groups, grids, disabled state and section geometry — while preserving RFC 6901 pointers back to the source JSON, through block and plugin expansion. Rules then inspect facts from that model.

This makes checks such as effective font size and available table width more useful than scanning raw props. It also lets generation and quality analysis reuse the same prepared document.

Quality is additive to validation:

  • malformed or structurally invalid input belongs to validation;
  • valid input is evaluated against every enabled quality rule;
  • a rule failure is reported separately from a design finding;
  • generation warnings are a third channel for recoverable renderer problems.

See the PPTX warning reference for that last channel.

Read the result ​

The core analyzers return QualityAnalysis:

ts
{
  diagnostics: QualityDiagnostic[];
  counts: { error: number; warning: number; info: number };
  blocked: boolean;
  truncated: boolean;
  suppressedCount: number;
  evaluatedRuleIds: string[];
  ruleErrors: { ruleId: string; message: string }[];
  profileId?: string;
}

A diagnostic contains:

ts
{
  source: 'quality';
  ruleId: string;
  code: string;
  severity: 'error' | 'warning' | 'info';
  category: string;
  certainty: 'deterministic' | 'measured' | 'estimated' | 'rendered' | 'evaluative';
  blocking: boolean;
  message: string;
  path: string;
  suggestion?: string;
  relatedPaths?: string[];
  evidence?: { actual?: unknown; expected?: unknown; unit?: string };
  fixes?: JsonPatchOperation[];
}

severity says how much the finding matters. certainty says how the conclusion was reached. Promoting an estimated finding to error does not make it deterministic; consumers can show or gate the two dimensions independently.

blocked is the policy verdict. Do not derive it from counts: a profile or policy can change severity without enabling a gate.

Counts cover all unsuppressed findings before the output budget is applied, so they can be larger than the returned diagnostics array when truncated is true.

Built-in PPTX rules ​

RuleCodesDefaultCertaintyWhat it checks
pptx/canvasW_QUALITY_CANVAS_UNSPECIFIED, W_QUALITY_CANVAS_NONSTANDARD, W_QUALITY_CANVAS_LEGACYwarning when missing; otherwise infodeterministicMissing, legacy 4:3 or nonstandard canvas dimensions
pptx/minimum-font-sizeW_QUALITY_FONT_SIZE_MINwarningmeasuredEffective text size below minimumFontPt (7pt by default)
pptx/text-fitW_QUALITY_TEXT_OVERFLOW, W_QUALITY_TEXT_TIGHTwarning for overflow; otherwise infoestimatedEstimated text height exceeds, or nearly fills, its box
pptx/slide-densityW_QUALITY_SLIDE_DENSITYwarningestimatedBody text exceeds maximumBodyWords (130 by default)
pptx/text-contrastW_QUALITY_TEXT_CONTRASTwarningdeterministicText falls below WCAG AA against the surface behind it
pptx/placeholder-textW_QUALITY_SCAFFOLD_MARKER, W_QUALITY_PLACEHOLDER_TEXTwarningdeterministicAn unfilled scaffold slot, or leftover filler text
pptx/box-overlapW_QUALITY_BOX_OVERLAPwarning for a duplicate or covered data; otherwise infodeterministicTwo opaque boxes on one slide that land on each other
pptx/chart-designW_QUALITY_CHART_3D, W_QUALITY_CHART_OVERLOADED, W_QUALITY_CHART_AXIS_BASELINE, W_QUALITY_CHART_SERIES_COLORS, W_QUALITY_CHART_UNITS, W_QUALITY_CHART_ANNOTATIONwarning, except units and caption, which are infodeterministicWhat a chart claims about its numbers
pptx/table-designW_QUALITY_TABLE_NUMERIC_ALIGN, W_QUALITY_TABLE_MIXED_DECIMALS, W_QUALITY_TABLE_GRID, W_QUALITY_TABLE_ROW_COUNTwarning, except the grid, which is infodeterministicHow a table lays its numbers out, and how long it runs
pptx/font-countW_QUALITY_FONT_COUNTwarningdeterministicMore than maximumFamilies (3) font families in one document
pptx/palette-adherenceW_QUALITY_OFF_PALETTEinfodeterministicA literal colour the resolved theme does not define
pptx/off-canvasW_QUALITY_OFF_CANVASwarningestimatedText ink, placed by its alignment and estimated with the fit width model, more than tolerancePt (2) past a slide edge
pptx/slot-budgetW_QUALITY_SLOT_BUDGETwarningdeterministicA block slot over the word budget its definition declares
pptx/required-chromeW_QUALITY_CHROME_MISSINGwarning; off unless a profile names required rolesdeterministicA block slot with a role the profile requires left empty
pptx/slide-footerW_QUALITY_CHROME_MISSINGwarning; off unless a profile names required partsdeterministicA block-built slide from fromSlide (1) on without the page number or footer line the profile expects; slides drawn by coordinates and hidden slides aside
pptx/action-titleW_QUALITY_ACTION_TITLE_LENGTHwarning; off unless a profile sets maxLinesestimatedAn action-title slot wrapping past the lines the profile allows
pptx/type-scaleW_QUALITY_TYPE_OFF_SCALEwarning; off unless a profile enables itdeterministicAn authored size the theme never paints; the fix snaps it to the nearest size on its scale
pptx/size-countW_QUALITY_TYPE_SIZE_COUNTwarning; off unless a profile enables itdeterministicMore distinct text sizes in the deck than maximumSizes (8), or on one slide than maximumSizesPerSlide (0: no ceiling), blocks and rich-text runs included, hidden slides not; no fix
pptx/role-driftW_QUALITY_TYPE_ROLE_DRIFTwarning; off unless a profile enables itdeterministicA named style or type role painted at two sizes; the fix restores the theme's size
pptx/title-driftW_QUALITY_TITLE_DRIFTwarning; off unless a profile enables itestimatedA title whose laid-out first baseline or aligned edge is away from where the deck's other titles of that kind land
pptx/bullet-densityW_QUALITY_BULLET_COUNT, W_QUALITY_BULLET_LENGTHwarning; off unless a profile sets a bounddeterministicMore bullets in one box, or more words in one bullet — a lone bullet included — than the profile allows; reported at the slot a block filled the box from
pptx/safe-areaW_QUALITY_SAFE_AREAwarning; off unless a profile enables itmeasuredA box outside the theme's safe area that is neither chrome (a tracker, footer, source or logo, by slot role or style) nor a full bleed; judged box by box inside a block
pptx/slide-titleW_QUALITY_SLIDE_UNTITLEDwarning; off unless a profile enables itdeterministicA slide carrying content with no action title and no title-styled box, rich-text titles included
pptx/figure-labelW_QUALITY_FIGURE_UNLABELLEDwarning; off unless a profile enables itdeterministicAn image with no alt text, a background that bleeds off the slide aside; reported at the slot an image filled
pptx/image-aspectW_QUALITY_IMAGE_ASPECTwarningdeterministicAn image drawn at an aspect the asset does not have

The three type rules read the resolved PPTX theme exactly as their DOCX twins read theirs: styles after the design system has projected every type role onto it, the deck's default size, and each step of the theme's type scale for the canvas in play. A custom theme is therefore judged by its own sizes. pptx/title-drift has no theme value to read — no theme states where a title goes — so it compares each title with the deck's other titles of the same kind: the same style, set at the same size, with the same alignment, whether an actionTitle slot of any block or a box in one of the titleStyles placed by hand. It judges the laid-out title rather than the box: the first baseline estimated with the fit width model at the size the fit pass left, inside the text insets and under the vertical anchor, and the edge the alignment holds (the left edge, the centre line or the right edge). Titles more than two of their lines apart are separate placements — a statement slide's assertion in the middle of the slide is not a content title that drifted — and a finding's relatedPaths name the titles that agree.

pptx/slide-footer is the deck twin of docx/running-head. It judges only slides a block builds, from fromSlide on: a page number is a text carrying the {PAGE_NUMBER} field, a footer is a text in the theme's footer type or a filled footer slot. consulting-deck requires the page number, which every house content block draws; the cover is slide 0 and exempt.

The canvas rule recognizes these deliberate presets: 16:9 standard (13.333 × 7.5), 16:9 small (10 × 5.625), square (7.5 × 7.5), 4:5 vertical (7.5 × 9.375) and 9:16 story (4.5 × 8). If no size is declared, the renderer uses a 10 × 7.5 inch 4:3 canvas, so the missing-canvas finding is a warning rather than informational.

Contrast compares the resolved run colour against whatever actually sits behind the text: its own shape fill, else the topmost earlier-drawn shape covering it, else the slide background. Gradients are sampled at the text box rather than reduced to their harshest stop, and a background the analyzer cannot see through — an image, a chart — produces no finding instead of a guess. Thresholds follow WCAG 2.1 AA: normalRatio: 4.5, dropping to largeRatio: 3 at largeTextPt: 18.

Text fit uses characterWidthFactor: 0.46 and safetyBufferPt: 8 by default. It is an estimate, not a rendered measurement: it considers box dimensions, effective font size, line spacing and paragraph spacing. A spill greater than one line height is W_QUALITY_TEXT_OVERFLOW; a smaller spill or a margin below the safety buffer is W_QUALITY_TEXT_TIGHT.

PPTX limits ​

  • Disabled components do not contribute facts.
  • Minimum-font and density checks can inspect positioned or unpositioned plain text. Text-fit additionally needs a resolved width and height.
  • Rule evidence names who set what a finding measures against in evidence.values.source: profile, policy or rule (the rule's default), the latest layer among whoever switched the rule on and whoever set the parameters it read; a ceiling a policy tightened over a profile says policy.
  • Text authored with runs has facts of its own: each run's size counts for pptx/type-scale, pptx/size-count and pptx/role-drift, a title set in runs titles its slide, and a box of runs styled as chrome is exempt from the safe area. Minimum-size, fit, contrast and density checks still skip it rather than guess at mixed run styles.
  • Density counts body words, not titles or all visible characters.
  • Contrast covers colours the document states. Text over an image or a chart is skipped, and a box laid across a gradient is judged at its worst sampled point — which can be a shortfall no single ink colour resolves.
  • The analyzer does not render slides. It cannot judge overlaps, image quality, alignment, factual correctness or narrative quality.

These omissions are deliberate. Missing evidence produces no finding rather than a confident-sounding guess.

Box overlap ​

Only opaque boxes take part: an image, a chart, a table, or a rectangle with an opaque fill. Those paint their whole rectangle, so two of them intersecting really do hide each other, which is a claim that needs no renderer. A text box supports no such claim — authors routinely declare one far larger than the words inside it, and reference-quality decks are full of designs where two text rectangles cross and no ink does: an 80pt title beside a 12pt label, a value centred in the hole of a donut chart. Word-level overlap is the rendered pass's job.

Transparency disqualifies a fill, and only rect and roundRect count: an ellipse, a pie wedge or a chevron leaves most of its bounding box empty, and decks stack tinted discs and radial segments whose boxes cross by design.

Intersecting is not the same as wrong, so the verdict is split. Two opaque boxes crossing is info — an accent strip along the top of a card, a badge in the corner of a photograph. Two cases are warnings, because neither is ever a design: a box whose geometry matches another to within two points is a leftover duplicate, and anything covering a chart or a table covers data. A box fully inside a larger one is layering, and is not reported; two equal rectangles are the duplicate case, and are.

Font families and palette ​

font-count counts the distinct families a document can paint: the theme's heading and body roles plus every family named in the document outside its theme. A theme's mono and light roles are not counted — they paint nothing until a component asks for them, and counting an unused Courier New would flag a report that uses one typeface. Past three families a document reads as assembled rather than designed, which is a warning; the limit is the maximumFamilies parameter.

palette-adherence reports a colour written as a literal that the resolved theme — named theme plus any in-document overrides — does not define, and offers the nearest token as an RFC 6902 fix. Nearest is by the "redmean" approximation, which ranks near-neighbours much the way an eye does; ties break on token name so the same document always emits the same fix. It is info because an off-palette colour is often deliberate, a client's own brand red in an otherwise on-theme report; the finding makes the choice visible rather than overruling it. A colour is recognised by where it sits — a property whose name mentions colour, fill, stroke, background or border — so a hex inside a sentence stays prose.

Neither rule judges the theme itself. A deck's inline props.theme and a report's props.themeOverrides define the palette and the families rather than paint against them, so a theme written into the document is read exactly as the same theme passed by name.

Placeholder text and scaffold markers ​

Both formats run the same check over every authored string, and answer with two different codes because the two states have different consequences.

W_QUALITY_SCAFFOLD_MARKER is a deliberate draft state: a slot still holding the {{…}} marker a scaffold wrote into it. jto_validate reports the markers and still answers ok: true — a draft is a legitimate thing to hold — but jto_generate refuses the document with an E_SCAFFOLD_MARKER error per remaining slot, because a generated file is what someone sends on.

W_QUALITY_PLACEHOLDER_TEXT is leftover filler: lorem ipsum, "Your title here", "Click to add title", a whole-string [bracketed placeholder], or bare authoring debris (TODO, XXX). Nobody put it there on purpose and nobody but the author can be certain it is not the real copy, so it only ever advises and never blocks. Deliberate values authors do write — TBD, N/A, a citation like [1] — are not placeholders and are never flagged.

The scan visits every string in the document, not a list of text-bearing properties: an allowlist drifts as components gain properties, and a marker it misses is a marker generation lets through. Components with enabled: false are skipped — they never reach the page. Neither code carries a fix; only the author knows what the sentence was meant to say.

The gallery templates are demonstration documents whose copy is lorem ipsum, so they carry this finding by design; the calibration suite records the count per template rather than suppressing the rule.

Every built-in rule carries a one-sentence description — the field is optional on the QualityRule contract, so a rule pack of your own may omit it and the guide prints nothing in its place. The MCP server renders the whole pack — with each rule's code, category, default severity, certainty, parameters and whether a profile has to switch it on — into jto://guide/design/<format> beside the themes, profiles, blocks and blueprints, so the guidance an agent reads is generated from the data the rules run on.

Built-in DOCX rules ​

RuleCodeDefaultCertaintyWhat it checks
docx/table-widthW_QUALITY_TABLE_WIDTH_OVERFLOWwarningdeterministicExplicit column widths exceed the usable width of their section, with a 10-twip rounding tolerance
docx/heading-hierarchyW_QUALITY_HEADING_SKIPinfodeterministicA heading jumps down by more than one level; the fix sets the level, except on a heading a block compiled, whose level is the definition's
docx/text-fitW_QUALITY_TEXT_OVERFLOWwarningestimatedA word too wide for its floating frame, or a frame whose wrapped block runs off the sheet
docx/frame-collisionW_QUALITY_FRAME_COLLISIONwarningestimatedTwo page-anchored floating frames whose estimated text blocks land on the same region of a page
docx/svg-text-boundsW_QUALITY_SVG_TEXT_CLIPPEDwarningdeterministicA <text> baseline in an inline SVG falls outside the viewBox, so the words are never painted
docx/line-boxW_QUALITY_LINE_BOX_COLLAPSEwarningmeasuredAn exactly line box on text is shorter than the capitals it holds
docx/placeholder-textW_QUALITY_SCAFFOLD_MARKER, W_QUALITY_PLACEHOLDER_TEXTwarningdeterministicAn unfilled scaffold slot, or leftover filler text
docx/slot-budgetW_QUALITY_SLOT_BUDGETwarningdeterministicA block slot holding more words than the block allows it, reported at the authored slot
docx/chart-designW_QUALITY_CHART_3D, W_QUALITY_CHART_OVERLOADED, W_QUALITY_CHART_AXIS_BASELINE, W_QUALITY_CHART_SERIES_COLORS, W_QUALITY_CHART_UNITS, W_QUALITY_CHART_ANNOTATIONwarning, except units and caption, which are infodeterministicWhat a chart claims about its numbers
docx/table-designW_QUALITY_TABLE_NUMERIC_ALIGN, W_QUALITY_TABLE_MIXED_DECIMALS, W_QUALITY_TABLE_GRID, W_QUALITY_TABLE_ROW_COUNTwarning, except the grid and the row count, which are infodeterministicHow a table lays its numbers out, and how long it runs
docx/required-chromeW_QUALITY_CHROME_MISSINGwarning; off unless a profile names required rolesdeterministicA block slot with a role the profile requires — a takeaway, a source — left empty or holding only whitespace
docx/running-headW_QUALITY_CHROME_MISSINGwarning; off unless a profile names required partsdeterministicA section from fromSection on without the header, footer or page-number field the profile expects
docx/exhibit-requiredW_QUALITY_EXHIBIT_MISSINGwarning; off unless a profile enables itdeterministicFewer charts or tables of two or more columns than minimumExhibits (1); a kpi-row does not count; no fix
docx/type-scaleW_QUALITY_TYPE_OFF_SCALEwarning; off unless a profile enables itdeterministicAn authored size the theme never paints; the fix snaps it to the nearest size on the theme's scale
docx/size-countW_QUALITY_TYPE_SIZE_COUNTwarning; off unless a profile enables itdeterministicMore distinct text sizes in the document than maximumSizes (8), blocks, statistics, lists and contents entries included; counted per document; no fix
docx/role-driftW_QUALITY_TYPE_ROLE_DRIFTwarning; off unless a profile enables itdeterministicA heading level or paragraph style painted at two sizes; the fix restores the theme's size
docx/body-measureW_QUALITY_BODY_MEASUREwarning; off unless a profile enables itestimatedA section whose body copy runs outside 45–90 characters a line; the report profiles judge at 125
docx/section-contentW_QUALITY_SECTION_EMPTY, W_QUALITY_SECTION_UNTITLEDwarning; off unless a profile enables itdeterministicA section that renders nothing of its own — no heading, contents field, copy or figure — or one carrying 60+ words under no heading
docx/heading-keep-nextW_QUALITY_HEADING_ORPHANwarning; off unless a profile enables itdeterministicA heading a page break can strand; the fix adds keepNext
docx/figure-labelW_QUALITY_FIGURE_UNLABELLEDwarning; off unless a profile enables itdeterministicAn image with no alt text and no caption: its own, a figure block's, or a Figure … paragraph right before or after it
docx/image-aspectW_QUALITY_IMAGE_ASPECTwarningdeterministicAn image drawn at an aspect the asset does not have
docx/contents-missingW_QUALITY_CONTENTS_MISSINGinfo; off unless a profile sets minimumHeadingsdeterministicAs many headings as the profile's minimumHeadings, or more, without a table of contents

The three consistency rules divide their evidence the way the whole system does: the theme supplies the values and the profile supplies the requirement. The measure is estimated: an average advance over ordinary English at the section's body size, across the section's usable width. Its own defaults are book typography, 45–90 characters, for a caller who enables the rule directly; the client- and technical-report profiles judge at 45–125 instead, by measurement rather than preference: a body line on the bundled themes' own pages runs 97 characters (minimal on A4) to 121 (devportal on Letter), the reports of the #362 blind review shipped in that range, and 125 still catches an 8pt body or half-inch margins. executive-report does not enable it. A section that lays any of its text in a columns component has no measure fact — the column, not the page, is the measure there, and answering with the page's number would be worse than saying nothing. The image-aspect rules read the asset only where the document carries it: a base64 data URI, and in DOCX an inline SVG's viewBox. A path is resolved against a base directory at generation time, so a distorted file-backed image is the rendered pass's to catch. Neither rule speaks when one side is left for the asset to supply, or when PPTX sizing fits the image to its box. Where the author wrote both sides as plain numbers — pixels in DOCX, inches on a slide — the finding carries a fix that rewrites the side the asset should have supplied to the value its shape gives, rounded towards the box, so the picture shrinks into the box it was given rather than past it.

PPTX does not read an inline SVG's viewBox, by measurement (#452): of 74 decks — the stock templates, the 2.0.0 baselines and every checkpoint and verification run — only the three stock decks place inline SVG images, and the twelve that miss their viewBox by more than 2% are icons a quarter to half an inch across, 0.4 to 2.4pt off. The 2% tolerance was calibrated on raster pictures; a tolerance for icons would have to be measured on what a reader sees, and no authored deck yet gives that measurement anything to protect.

pptx/safe-area offers a fix only where moving the box is the whole repair: a box placed directly on the slide with plain inch coordinates and no larger than the safe area. The fix moves x and y just inside the margin. A box too big for the margin needs a smaller box or a bleed, which is a design call, and a box a group or a block places sits in a frame the fix does not rewrite.

docx/type-scale reads every size the resolved theme paints — its named styles, its font roles, every step of its declared type scale — so a custom theme is judged by its own list and a block's compiled paragraphs, whose sizes bind to those styles, are never reported. docx/size-count counts what reaches the page — blocks, a statistic's figure, unit and description, a list's items and the contents entries a theme styles included — against a ceiling of eight that a profile or a policy can move through maximumSizes, and it offers no fix, because which size to drop is a judgement. It counts per document by decision rather than per page (#332): a report's pages share one type system and no page paints a size the document does not, so the document's ceiling bounds every page's; the static pass has no page model for flowed text; and one dense page of the house blueprint already paints most of the house sizes, so a per-page ceiling below the document's would fail the blueprint's own pages. A deck, whose slide is its page, is counted both ways: pptx/size-count holds each slide to maximumSizesPerSlide as well. A size the theme's component defaults or the fit pass wrote into the props is counted but never offered as a patch — the author wrote no member for a replace to reach. The profiles' ceilings are measured, not chosen: a client report built from the house blocks paints up to nine sizes (a statistic paints in the theme's stat and label roles, so the kpi-row adds only its figure's scale steps), a technical report eight against a ceiling of nine, a consulting deck nine across the deck and six on its busiest slide; every checkpoint and verification document of that archetype came in at or under its profile's number. docx/type-scale yields to docx/role-drift on any pointer the latter reports, so one size never receives two different fixes, and it reports one finding per role and size whose patch snaps every place together, so a repair never leaves the role at two sizes. docx/role-drift groups text by the style it inherits — heading2, normal, a themeStyle — and reports an authored size that departs from the theme's size for that role while the role is painted at more than one size; a role overridden the same way everywhere is a choice, not drift. pptx/role-drift groups by named style and type role only: text with no style has no role on a slide, measured rather than assumed (#452). Treated as the deck default, unstyled text would report nothing on the sixteen verification decks built from the house blocks, which style every box, and 115 boxes on the three stock decks and about 1,980 on the 2.0.0 baselines, where nearly every one is a deliberately sized figure or label rather than one role at two sizes. pptx/type-scale and pptx/size-count already judge those sizes.

All three read every surface that paints text, not only body paragraphs: headings and paragraphs, table cells and header cells — including a component nested in a cell, which lives outside the component tree the walk reaches — as the tableCell and tableHeader roles, counted against the size every untouched cell inherits, and the paragraphs of a section's running head and footer (as the header and footer roles, which the theme gives no size of their own, so they are checked against the scale and never for drift). Each finding points at the pointer that wrote the size — …/props/font/size on a node, …/props/columns/0/cells/2/font/size on a cell — and a size a block compiled from its own definition is never reported, because the author has nothing there to patch. Each finding's evidence.values.source names theme or profile. All three are off on the default profile: an editorial layout sets display sizes by hand, and only an archetype decides that a document must keep to the scale.

Frame text fit only inspects paragraphs pinned into a floating frame, where the author rather than the layout engine decides the available room; flowed body copy repaginates and needs no check. Its width model sums per-character advances — a single characters-per-point factor cannot serve both caps and lowercase, which measured 0.694 and 0.435 em per character on the same face — and it reports only past an 8% tolerance, the measured error of that model. An overrun smaller than one line height is likewise ignored. Marginal cases are therefore out of reach by construction, and belong to a rendered-certainty pass rather than a static estimate.

Frame collision compares those same pinned frames against each other: each frame becomes a rect from its authored offsets and width plus its estimated wrapped-text height, and two rects on the same page that share more than a sliver of width and more than one line of height are reported as painting over each other. Consecutive paragraphs with identical frame properties are one flowing OOXML frame — the stock stat cards stack a number, caption and body this way — so a chain is measured as a single frame, never as members colliding with themselves. The one-line floor exists because the height estimate inherits the width model's error, and because tight editorial layouts deliberately tuck captions into the slack of a display digit's line box; an overlap has to cost real text before it is worth a warning.

SVG text bounds needs no estimate: the baseline and the viewBox are both authored numbers, and text below the canvas is dropped from the PDF text layer as well as the page, so it escapes search, copy and screen readers.

The line-box rule guards the other half of the legibility floor. font.size stops at 8pt because smaller type cannot be read; the box the glyphs sit in has no floor of its own, so { "type": "exactly", "value": 1 } on 8pt text is a schema-valid 1pt line. It fires only on exactly, which pins an absolute height — atLeast and the multiples can only ever be as tall as the text needs — and only on a paragraph or heading that has text: an empty paragraph with a collapsed box is how a thin spacer is drawn, and the stock templates draw them that way.

The floor is relative, at 0.7 em: cap height on the faces the stock templates use, and below every legitimate value in the reference corpus, whose tightest exact box is 10pt on 12pt type. Tight display leading is a real technique and stays silent. The repair grows the box to one em rather than to the floor, because a box at the floor still collides with the line below it: rendered at 8pt, stacked lines touch at 0.7 and 0.8 em, clear at 0.9 and are clean at 1.0. The size compared against is the one Word will lay out — authored, from componentDefaults, or inherited from the paragraph style, which either states a size or names the theme font that carries one.

The finding also names the way out. A collapsed line box is usually not a leading mistake but someone drawing a line, because until the divider component there was nothing else to draw one with: font.size floors at 8pt, so an author after a 3pt line collapsed the box instead. divider owns that construction — the same empty paragraph, the same border, the same collapsed box — on a paragraph with no glyphs to clip.

Table cells are out of scope: a cell's row can grow past its line box, so the same geometry there is not the same defect.

Table analysis accounts for the actual section width and margins. It reports a repair patch only when every column width is explicit, because proportional scaling is then deterministic. Heading analysis reports the source path of the level and can patch it to the next valid level.

DOCX quality coverage is intentionally narrow today. It does not evaluate prose, typography, whitespace, widow/orphan behavior, color or the visual result produced by Word.

The rendered pass ​

Every rule above reads the document; one pass reads the page. jto_preview with renderedFindings: true converts through LibreOffice as it does for pixels, then reads the PDF's word geometry (pdftotext -bbox-layout) and embedded fonts (pdffonts) and reports what only ink can show, with certainty: "rendered":

RuleCodeSeverityWhat it reports
rendered/clipW_QUALITY_RENDERED_CLIPwarningWords past the page edge by more than 2 pt, or a paragraph of which only a leading part rendered (kind: truncated, with the share found)
rendered/spillW_QUALITY_RENDERED_SPILLwarningA framed paragraph or slide text box drawn taller or wider than the box it declared
rendered/overlapW_QUALITY_RENDERED_OVERLAPwarningTwo words from different lines whose boxes intersect by more than 30% of the smaller
rendered/text-missingW_QUALITY_RENDERED_TEXT_MISSINGwarningAn authored string that appears nowhere in the PDF: fully clipped, hidden, or dropped by the renderer
rendered/placeholderW_QUALITY_RENDERED_PLACEHOLDERwarningDOCX: the renderer's [Unsupported component type: …], painted where a table cell held a component it cannot render — at the cell, or unmapped on its page
rendered/font-substitutedW_QUALITY_RENDERED_FONT_SUBSTITUTEDinfo / warningA requested family the PDF embeds no face of; a warning when the document declared a source for it, information when the host simply lacks it
rendered/empty-pageW_QUALITY_RENDERED_EMPTY_PAGEinfoA wordless page (kind: blank), or in docx one whose only words are its running head or footer (kind: chrome-only)
rendered/heading-strandedW_QUALITY_RENDERED_HEADING_STRANDEDwarningDOCX: a heading that is the last body line on its page
rendered/paragraph-splitW_QUALITY_RENDERED_PARAGRAPH_SPLITinfoDOCX: a paragraph leaving one line alone on either side of a page break (kind: orphan or widow)
rendered/table-splitW_QUALITY_RENDERED_TABLE_SPLITinfoDOCX: a table broken across a page badly — its header alone at a page foot (kind: header-alone), or one row alone on either side of the break (kind: orphan-row or widow-row)
rendered/page-underfilledW_QUALITY_RENDERED_PAGE_UNDERFILLEDinfoA docx page whose ink stops well above the footer: a middle page under half filled (kind: middle-page) or a last page under a quarter filled (kind: last-page); context.fill is the share; needs pdftoppm

Findings map back to authored pointers through the document's text inventory: every painted string with its role, in reading order, read off the prepared document so a string a block compiled reports at the slot the author wrote. Matching folds both sides to lowercase alphanumerics (ligatures split, punctuation and bullets drop out) and searches one stream over the whole document, so a paragraph that breaks across a page is still one match; running heads and footers are matched first and their rows removed, so a page number never interleaves the two halves. Duplicate strings are attributed in reading order — "Total" in the second table finds the second "Total" — and an entry whose every occurrence was already claimed is ambiguous rather than guessed. A claim holds every word it matched, so a tracker that says "Revenue" cannot take the word from inside a title that opens with it. When the whole string is absent, its longest rendered prefix (at least 16 folded characters) maps it and reports the truncation.

A deck knows more than a report before it renders, and its entries say so. A slide is its own page, so slide text is looked for only on the page its slide exports to — hidden slides are left out of the export and out of the inventory — and its text box sits where the deck put it, so of the occurrences on that page only one that reaches into the box is its own, and the one lying most inside it wins. Text that overflowed onto other text shares its rows with the words it was drawn over and no run of the stream spells it; down its box's column, from the box's top, its own words still come in order with only those words between, and it is matched there, skipping the fewest. Text set at an angle comes out of the PDF in fragments no reading order can join: it claims an occurrence when one matches, is never reported missing, and is held to no box.

A last-page finding whose page mapped at all points at the section that closes the document rather than at the section its text was authored in — the notes are painted from source slots the sections that cited them own, so the text furthest down a stub page names a section near the front — and its advice names the move that repairs it: pageBreak: true on that section, or on the one before it when the closing section already starts a page. That was chosen by measurement, not by argument — on the four documents of the #405 exhibit set that closed on a stub page, every position in the last two sections that can carry a break was tried, and a break on a section was the only move that cleared all four. Keeping the notes with the paragraph above them, which the earlier advice suggested, cleared one; moving the notes ahead of the closing prose cleared none. The recorded documents are the regression inputs (packages/design-evals/src/last-page-repair.test.ts).

Reading order is decided from geometry, which is what lets a wrapped table cell match its own authored text. A row is parted into cells at gaps wider than half a line — and, where cell padding is tighter than that, at any gap the row's own word spacing cannot account for: within one row a word space holds to its median within a hundredth, since justification stretches every space of a line alike, while a cell boundary poppler ran together is close to twice it. A run of rows that resolves into more than one column reads column by column, so a cell's lines come out together even when no single row holds two cells — a figure centred between the two lines of the label beside it parts them exactly as a full row of cells does. Prose over the cells does not merge them into its own column: a row that sets two fragments inside one column shows the column was several all along, so what it holds so far is read first and each fragment opens a column of its own. A word joins a row only beside a word of about its own size (within twice its height), so a display title's word boxes, which span both lines of the small print set beside it, never read those lines one word from each at a time. Rotated text — a chart's value-axis title — reads as one run after the upright text of every page, so it can never fall between two lines of a paragraph: not beside a chart where poppler emitted it, and not at the top of a page a paragraph turns onto. Poppler's line grouping marks a rotated title's short words ("of") too. A bare-digits row inside a page band is a page number only when it stands alone, with no unclaimed row within a line of it, and its height recurs on another page; a table's numeric row that flowed into the band on one page is neither, and keeps its own occurrence.

Word boxes are font metrics, not ink. Poppler sizes a word by its face's ascent and descent — 1.1 to 1.6 times the type size across the bundled and stock faces — so a display title set tight runs its word boxes past a box it visibly fits, and a label set under a large figure sits inside the figure's descent. Spill and overlap are judged on a word's ink band, its box with a fifth of its height taken off the top and the bottom (WORD_BOX_SLACK): on the stock deck templates, font slack reaches 16% of a word's height past a box and 31% into the line under it, while text that truly overflows runs 70% of a line or more. A slide text box, whose position is known, holds its ink to its own top and bottom edges; a box that states only its size is held to that size.

context.mapping on every finding is mapped, ambiguous (one side of a pair) or unmapped (no authored string owns the words; reported at the document root, never dropped), and the rendered summary counts inventory entries and findings by outcome. Two kinds of string are painted without being authored where they show, and the inventory lists them where they render so they cannot steal a heading's first occurrence: a table of contents repeats the headings and style-mapped paragraphs it collects (optional entries, since which lines a field collects is the field's decision), and a native chart draws its title, unless hidden, and its axis titles inside the drawing.

The pass is a rule pack of its own (RENDERED_QUALITY_RULES in jto-ops), run through the same quality engine as the static rules under the same profile and policy jto_validate takes — jto_preview's quality option, else the profile the document declares, else the format default. A profile can switch a rendered rule off or move its severity, a policy can suppress one at a pointer, and a gate marks findings blocking, and a maxDiagnostics budget cuts the list; rendered.suppressed, rendered.blocked, rendered.truncated and rendered.profileId say what applied, and a rendered rule that fails is reported as a rule error rather than an empty result. The design guide lists the rendered rules beside the static ones. Generation never runs the pass, so nothing here blocks jto_generate. It runs over geometry the caller extracts (analyzeRenderedDocument), so it is testable from captured fixtures without a converter, and a rendered preview prepares the document once: the same prepared model is generated from and mapped through.

A labelled mapping corpus (preview-rendered-mapping-corpus.test.ts, converter-dependent) pins the mapping on report- and deck-shaped documents: duplicate strings in two tables and a contents page, ligature-prone words, running chrome around a paragraph that breaks across pages, a family declared with a source that cannot load, a native chart's titles on both renderers, a table broken badly across a page and a frame whose tail never rendered; and on slides, a takeaway beside a chart with a rotated axis title under a tracker that repeats the title's first word, figures that fold alike on two slides with a hidden slide between them, text past its box and past the slide's foot, and a text box placed off the slide — the fully clipped text a report cannot produce portably, since LibreOffice pulls an off-page frame back onto the page but exports a slide's off-canvas shape outside it. Every entry is labelled with the page it must land on; precision and recall against the labels are 1.0 on 63 entries, above the ≥0.95 / ≥0.90 targets of #344.

Measured on decks (2026-09-15). Every clip and spill the pass reported on the 20 decks of the #409/#422 verification sets that render — 91 findings — was the matcher's: re-mapped, each of those strings rendered whole, on its own slide, inside its box. They now report none, with no entry left ambiguous or missing. On the four stock deck templates, word boxes read at their font metrics had reported 100 findings; read as ink, 6 remain, each confirmed on the page (a second line in a box drawn for one, a label printed over a title). On the ground-truth harness's mutated decks the pass flags every spill of more than a line (31 of 31, including the 19 the static estimator leaves below W_QUALITY_TEXT_OVERFLOW) and none of the boxes that fit.

Block coverage matrix ​

The rules are held to their own false-positive bar by a matrix over every JSON block the playground templates ship (#343). buildMatrixInventory in jto-ops reads each template with the extraction jto://blocks publishes, and states for every definition the conditions it is supported on — theme, canvas, font and slot edge — and a reason for each one it is not:

  • A template on a bundled theme is authored against theme roles, so its definitions are supported on every bundled theme of the format, in the design faces and in the fallback faces (DejaVu Sans, which every LibreOffice bundles and which sets wider than the house faces). A report's definitions are supported on A4 and Letter. A deck's are supported on the slide shape the template is drawn on, because slot budgets are measured there: consulting-deck-blocks lays out on 4:3 inside the theme's safe area, but a 4:3 slide holds fewer words, so its budgets are no promise there.
  • A template carrying its own theme object is authored against that object, with absolute geometry fitted to its own faces and slide, so its definitions are supported there only.
  • A definition without slots has one document, so min and max are the same case.

The profile a template's definitions are judged under is the one its blueprint names (client-report, technical-report, consulting-deck), else the format default. A new template, definition or bundled theme gains its cases without an edit to the matrix.

generateMatrixCases turns the inventory into documents: each definition at the edges of its own slot schema — minimum and maximum cardinality, every string at its word budget in words of the length reports use, every figure at its widest, the worst-case chart — in a small report or deck around the template's own chrome, and each whole template with every invocation at that edge. The static half (block-matrix.test.ts) judges every supported case: it must validate, prepare, and come back with no warning-severity finding, and every finding, advice included, must point at something the author wrote. The rendered half (preview-block-matrix.test.ts in mcp-server) renders the riskiest subset through LibreOffice and holds it to the rendered pass's warnings and mapping; JTO_BLOCK_MATRIX=full renders every case, and JTO_MATRIX_MANIFEST names a file that receives the converter versions and, per case, every family requested, whether the document declared a source and whether the PDF embeds it. The converter-dependent CI job keeps that manifest as an artifact.

Clean cases are half the evidence. Each suite also injects defects and expects them back where they were caused: a slot one word or one item past its budget (block_slot_budget at the slot), a definition that composes from a block the document lacks or invokes itself on every expansion (block_unknown_reference, block_expansion_limit at the ref inside the definition), a framed paragraph that cannot fit its frame, and a deck box cut to a sliver with its fit taken away (a rendered spill at the invocation of the block that drew the box, quoting the text). block-matrix-composition.test.ts covers what no template ships: user-defined blocks that nest others, the same block invoked again, and documents mixing blocks, registered code plugins and primitives, where a finding in a plugin's output reports at the invocation that ran it.

Measured when the matrix gained decks (2026-09-16). Statically, 927 cases, all clean once three defects were fixed: the consulting deck frames were a percentage of the slide — the half-inch safe area on 16:9 but 27pt on 4:3 — and now span the theme's grid across; action titles held 17 words at the house sizes, not the 24 their budget allowed; the statement assertion held 11, not 14. Rendered, the report blocks were clean once the matcher learned to read a memo header's tab-aligned lines as the rows they are rather than as a table. The deck's widest cases were not: the cover title wraps a line more than its fit estimate allows and prints over the subtitle, and the statement's support and the action chart's takeaway run past their boxes — on the house theme a few points, on themes without type roles and in the fallback faces more. The spills are not monotonic in the word count, because a fit steps down a size only when its estimate overflows, so no budget fixes them alone: the deck fit estimate needs calibrating against rendered ground truth. Until then the rendered suite tolerates exactly those findings at those blocks and slots on the deck's widest cases (DECK_FIT_DEFECTS) and lists them in the manifest. The gallery templates themselves stay covered by gallery-quality.test.ts: none may carry a warning beyond the true findings and illustrative placeholders recorded for it.

Charts and tables ​

Both formats ask the same questions of a chart and of a table, because both are questions about the numbers rather than about the file. The rules are written once and each format translates its own props into them, so a slide chart, a document chart and a Highcharts config are judged by one standard.

Charts ​

W_QUALITY_CHART_3D is the only one of these that is never a matter of taste. A perspective projection makes the front of the plot read larger than the back, so the comparison the chart exists for is the thing the depth distorts. PPTX spells it bar3D; a Highcharts config spells it chart.options3d.enabled. The DOCX chart component has no 3D type at all.

W_QUALITY_CHART_OVERLOADED is one code for two counts, because they are the same failure measured differently: more than six slices in a pie or a doughnut, or more than four series anywhere else. Which count applies is decided by how the chart encodes value — a pie encodes with angle, and wedges within a few degrees of each other cannot be ranked by eye; a line encodes with position, where twelve months is a time series and not an overloaded chart. The limits are the maximumSlices and maximumSeries parameters.

W_QUALITY_CHART_AXIS_BASELINE fires only where the chart encodes with length: a bar twice as long has to mean twice as much, so a value axis that starts at 80 draws a 2% difference as a doubling. A line chart with the same floor is left alone — zooming the axis is how a small movement is made visible, and position carries no claim about ratios. Only an authored floor counts; a chart that states none gets the renderer's, which starts at zero. The DOCX chart component has no axis-floor property, so this reaches DOCX only through Highcharts.

W_QUALITY_CHART_SERIES_COLORS reports a chart with no series colours, which paints in the renderer's default palette — the one that belongs to no document. The fix names one theme token per series, drawn from primary, accent, secondary and the optional accent4–accent6 slots, cycling if the chart has more series than the theme has slots. background and text are excluded: a series painted in either disappears into the slide. No fix is offered for a Highcharts chart — its palette lives inside an options object the schema keeps opaque and the export server reads verbatim, so writing into it would mean guessing at a structure nothing here validated.

W_QUALITY_CHART_UNITS is info, and generous about what counts as naming a unit: a value-axis label format, percent data labels, a percent-stacked bar, or a unit marker in the value-axis title, the chart title or the caption — a currency or percent sign, a parenthesised suffix like (€m), or one of a short list of unit words. A quantity named without its unit is not a unit: Revenue does not count and Revenue (€m) does. It advises rather than warns because the unit may well be in the sentence beside the chart, which the analyzer cannot see.

W_QUALITY_CHART_ANNOTATION is asked only of a chart that has somewhere to put the answer. A DOCX chart has props.caption; a Highcharts config has caption.text and subtitle.text. A native PPTX chart has neither, so it is never asked — a rule that judges a slot the component does not have is a rule nobody can satisfy. When blocks arrive, the takeaway and source slots they carry will be judged the same way.

Tables ​

A column is numeric when at least two of its body cells parse as numbers and no other cell is text. Blanks, dashes, n/a and TBD are gaps rather than text — real tables are full of them, and reading one as prose would hide exactly the column most likely to have been laid out without thought. Two numbers is the floor: a single figure beside a label is a fact, not a column.

Number parsing is positional rather than locale-aware, because there is no universal answer — 1.234 is one thousand two hundred and thirty-four in Milan and one-point-two-three-four in Chicago. Exactly three digits after the last separator is read as a thousands group and anything else as a decimal fraction, which reads 50,00 as fifty and 1,234 as one thousand two hundred and thirty-four. Currency symbols, percent signs, accounting parentheses and magnitude suffixes (k, m, bn, pp, bps) are notation and are stripped before parsing.

W_QUALITY_TABLE_NUMERIC_ALIGN reports a numeric column that is not flush right, where digits do not line up by place value. Alignment is read through each format's own cascade rather than off the cell, so a table that sets the alignment once for every cell in it is not reported as if every cell were silent. The repair is the one place the two formats genuinely differ: a DOCX table is column-major, so the fix is one operation on the column's cellDefaults plus one for the header and one for any cell that stated an alignment of its own; a PPTX table is row-major and has no column to patch, so the fix is one operation per row, and a plain-string cell becomes an object to carry the alignment at all.

W_QUALITY_TABLE_MIXED_DECIMALS reports a numeric column rounded more than one way. It carries no fix: padding 3 to 3.00 is a claim about precision the source may not support, and only the author knows which way the column should go.

W_QUALITY_TABLE_GRID is info, and is asked of the table's own declaration rather than of the resolved borders. Word's baseline is a box around every cell and every PPTX theme draws a rule between them, so a resolved-border test would report every table that never mentioned its borders — one finding per table for a decision the theme took once, for the whole document. A grid a table asks for is a choice the author can unmake at the pointer the finding names; a grid it inherited belongs to a theme review.

W_QUALITY_TABLE_ROW_COUNT counts the rows a table draws, header included. It warns past twelve on a slide, where the canvas is fixed and a thirteenth row is either unreadable or off the edge, and advises past twenty-five on a page, where nothing breaks and the limit is about attention rather than about paper. Both are the maximumRows parameter.

A table with merged cells is described without columns and none of the column findings apply: a colspan breaks the correspondence between an index and a visual column, and every column finding is about what sits under what.

Profiles ​

A profile describes the intended document class. It supplies rule parameters and severity defaults; it does not decide whether a run blocks. A document may name the profile it wants judged by in props.qualityProfile; validation without a profile argument uses it, a profile the caller passes wins, and an unknown name falls back to the format default. A blueprint scaffold writes its archetype's profile there, so the first jto_validate already judges against the right bar.

You can create a profile with any unique string id; it does not need to be registered with json-to-office. Save the object as JSON for the CLI, or pass the same object directly through the library, HTTP or MCP APIs.

ProfileFormatDifference from the format default
technical-presentationPPTXDefault PPTX profile
executive-presentationPPTX14pt minimum font; at most 70 body words per slide; at most five bullets of at most twelve words in a box; every content slide titled
consulting-deckPPTXRequires a slide footer with page numbers, takeaway and source slots, bounds action titles at two lines, at most 90 body words, at most five bullets of at most twelve words in a box, content inside the theme's safe area, every content slide titled, every image with alt text, every size on the theme scale with at most nine in the deck and six on a slide, and every title of one kind on one line
generalDOCXDefault DOCX profile: integrity and information-design rules at their defaults, nothing required by structure
client-reportDOCXRequires a running head with page numbers on every section after the first, takeaway and source slots wherever a block declares them, promotes heading skips, rendered empty pages and under-filled pages to warnings, enables the three theme-consistency rules with at most nine sizes, judges the body measure at 45–125 characters, reports empty and untitled sections, headings a page break can strand and unlabelled figures, and requires at least one chart or table; the client-report blueprint names it
technical-reportDOCXAs client-report, but owes a figure only its source, never a takeaway, allows nine text sizes, and asks for a contents page from eight headings; the technical-report blueprint names it
executive-reportDOCXPromotes heading skips from info to warning; reports empty and untitled sections and headings a page break can strand
legal-appendixDOCXCurrent integrity-focused DOCX defaults

Create a profile ​

For example, save this as board-deck.json:

json
{
  "id": "board-deck",
  "version": "1.0.0",
  "description": "Internal board presentation standard",
  "formats": ["pptx"],
  "rendererTargets": ["pptxgenjs"],
  "rules": {
    "pptx/minimum-font-size": {
      "parameters": { "minimumFontPt": 16 }
    },
    "pptx/slide-density": {
      "severity": "error",
      "parameters": { "maximumBodyWords": 60 }
    },
    "pptx/text-fit": {
      "severity": "warning"
    }
  }
}

Then select it for validation or generation:

bash
jto pptx validate deck.json --quality-profile board-deck.json
jto pptx generate deck.json --quality-profile board-deck.json

A custom profile starts from every rule's built-in defaults. Omitting a built-in rule does not disable it; set { "enabled": false } for that rule when needed. There is currently no extends field: a custom ID does not inherit a shipped profile such as executive-presentation.

Profiles configure rules already installed in the selected quality engine. An unknown rule ID is not installed or evaluated. Adding a genuinely new check requires a custom QualityRule/QualityEngine, not only a profile entry.

Customize a shipped profile ​

To adjust a shipped profile, keep its shipped ID and override only what differs. Rule configuration merges field by field, so shipped parameters not mentioned here remain in force:

json
{
  "id": "executive-presentation",
  "formats": ["pptx"],
  "description": "Board deck",
  "rules": {
    "pptx/minimum-font-size": {
      "parameters": { "minimumFontPt": 16 }
    },
    "pptx/slide-density": {
      "severity": "error"
    }
  }
}

Profiles may also declare version, description, rendererTargets and top-level parameters. A profile that does not support the current format or renderer is rejected instead of silently running under the wrong assumptions.

Policies and gates ​

A policy controls one run. It can override rules, suppress accepted exceptions, limit output and decide when findings block:

json
{
  "rules": {
    "pptx/text-fit": {
      "severity": "error",
      "parameters": {
        "characterWidthFactor": 0.48,
        "safetyBufferPt": 10
      }
    },
    "pptx/slide-density": { "enabled": false }
  },
  "suppressions": [
    {
      "code": "W_QUALITY_FONT_SIZE_MIN",
      "path": "/children/8",
      "pathMatch": "subtree",
      "reason": "Approved legal footer"
    }
  ],
  "gate": "warning",
  "maxDiagnostics": 100,
  "onRuleError": "throw"
}

Configuration precedence is:

  1. rule defaults;
  2. profile-wide parameters;
  3. profile rule configuration;
  4. policy rule configuration.

Later layers win. A rule can be enabled or disabled, assigned a severity and given parameters at the profile or policy level.

Gate thresholds ​

The threshold is inclusive:

GateBlocks
noneNothing
errorErrors
warningErrors and warnings
infoErrors, warnings and info findings

Default behavior is none. Setting a severity without a gate changes reporting, not the pass/fail verdict.

Suppressions ​

A suppression can select by ruleId, code, path, or a combination. All provided selectors must match. pathMatch: "exact" is the default; subtree also matches descendants. A selector-free suppression matches nothing, and every suppression requires a reason so exceptions remain auditable.

Suppressed findings are removed from diagnostics and counted in suppressedCount.

Validate hand-written policy JSON

The TypeScript contract requires reason and constrains suppression fields. Runtime validation currently checks the gate, rule-configuration shape and severity, diagnostic budget and onRuleError; it does not fully schema-check suppressions or arbitrary rule parameters. JSON callers should validate or construct those fields carefully.

Budgets and rule errors ​

maxDiagnostics is a non-negative integer and acts as an output budget. When it removes findings, truncated is true. Blocking findings are always retained, so the returned list can exceed the budget rather than hide why a gate failed.

onRuleError controls an unexpected exception from a rule:

  • continue (the default) records the failure in ruleErrors and evaluates other rules;
  • throw stops analysis and throws the underlying error.

A continued rule error does not itself create a blocking diagnostic. Use onRuleError: "throw" when CI must fail closed on faulty custom rules.

If preparing the document fails, analysis records quality/prepare. An active gate fails closed because no reliable quality verdict could be produced; an advisory run remains unblocked. Structural validation should normally catch the malformed input first.

Suggested fixes ​

Some deterministic or bounded findings include RFC 6902 operations in fixes:

  • raise a font to the configured minimum;
  • reduce a text font when a readable whole-number size fits the box;
  • proportionally scale fully explicit DOCX column widths;
  • replace a skipped heading level with the next valid level (not on a heading a block compiled);
  • bind a heading to the paragraph under it with keepNext;
  • snap an authored size to the nearest size on the theme's scale, or back to the theme's size for its role — in every place the size was written at once;
  • map an off-palette literal to the nearest theme token;
  • name one theme colour per series on a chart that states none;
  • right-align a numeric table column, header included.

Fixes are proposals, not automatic mutations. Review them before applying them, especially when reducing type size would preserve fit but harm the design intent. When no safe patch exists, the diagnostic still supplies a suggestion.

Programmatic analysis ​

The analyzers are exported by the format cores:

ts
import { analyzeDocxQuality } from '@json-to-office/core-docx';
import { analyzePptxQuality } from '@json-to-office/core-pptx';

const pptxAnalysis = analyzePptxQuality(deck, {
  profile: { id: 'executive-presentation', formats: ['pptx'] },
  policy: { gate: 'warning' },
});

if (pptxAnalysis.blocked) {
  throw new Error('Deck did not meet the quality policy');
}

const docxAnalysis = analyzeDocxQuality(report, {
  policy: { gate: 'error' },
});

These functions are not re-exported by @json-to-office/json-to-docx or @json-to-office/json-to-pptx. Direct calls to the core generation functions do not automatically enforce a quality policy either: analyze explicitly and act on blocked. The CLI, HTTP server and MCP server integrate analysis into their validation/generation flows.

Registered code components ​

A document that invokes registered code components is analysed through the generator that registers them. prepareQuality runs the same validation, theme context and bounded block and plugin expansion as generation, and nothing renders; hand its result to the analyzer:

ts
import {
  analyzeDocxQuality,
  createDocumentGenerator,
} from '@json-to-office/core-docx';

const generator = createDocumentGenerator({}).addComponent(revenueTable);
const { prepared } = await generator.prepareQuality(report);
const analysis = analyzeDocxQuality(report, { prepared });

createPresentationGenerator(...).prepareQuality does the same for decks. Every rule judges what a plugin emitted, and a finding inside that output is reported at the invocation the author wrote — on a slide, in a section, or at the block whose definition invokes the plugin — with no patch, because the author owns the invocation's props, not the emitted tree. The CLI and the playground pass their loaded plugins to the analysis this way; the MCP server registers none. The plugins render once for the analysis and once for the bytes.

For advanced integrations, the cores also export their prepared-document functions, built-in rule packs, profiles and engines. The @json-to-office/quality package exports QualityEngine and the shared rule, profile, policy and diagnostic types, so an application can add its own rule pack without changing the format cores.

HTTP and MCP ​

HTTP validation and generation accept quality settings under options.quality:

json
{
  "jsonDefinition": {
    "name": "pptx",
    "props": {},
    "children": []
  },
  "options": {
    "quality": {
      "profile": {
        "id": "executive-presentation",
        "formats": ["pptx"]
      },
      "policy": { "gate": "warning" }
    }
  }
}

The MCP jto_validate tool accepts the same profile and policy objects under quality. It returns evidence-rich diagnostics as repair targets; ok changes only when structural validation fails or the requested quality gate blocks. See The MCP server.

Released under the MIT License.