typograph-editor-documentation
    Preparing search index...

    Release Notes - Version 1.0.0-20260916

    Twenty-one fixes from three converted templates — a pizza menu, an onboarding brochure and a project proposal — and one new capability.

    The first two are about a document being read too literally in one place and not literally enough in another. Then text that a frame had stopped carrying, was drawing in the wrong order, or was refusing to let go of. Then the conversion itself: an outline with no colour to draw it in, a master page that reached only one of its pages, a shape measured by the last piece of its own outline, a master item delivered twice, and every stroke in the document a third too thick.

    And four that were invisible rather than merely wrong — a mirrored picture, a gradient stroke, a blend mode and text wrapping around a picture all had nowhere to go, so each arrived as something else entirely. Plus an addition, which exists because a template arrived without its fonts and nothing said so.

    • A font family whose plain face is not called "Regular" is used, rather than a heavier one from the same family. A heading set in Bodoni 72 came out bold: that family calls its plain face Book and has no Regular at all, so the face the text asked for was never collected from the package, and what was collected — the bold weight, named by another style — was drawn in its place. It is not a Bodoni peculiarity; a family that calls its plain face Book, Roman, Medium or simply R is ordinary, and each was being resolved by position rather than by name. The style shown in the toolbar now agrees with the letters on the page.

      This one needs the template converting again. The face that was missing has to be collected from the InDesign package, and a template converted before this release does not have it — until it is converted again, the heading keeps the weight it was given.

    • A tab fills the line in right-aligned text. A menu row is set with the dish flush left, the price flush right and a single tab between them, and the paragraph is right-aligned — the tab does the work. The tab was being given a fixed half-inch step instead, so the name and the price sat together near the right margin and the row read as though its alignment were wrong when it was exactly what the document said. This applies to every document the moment it is opened, and to the PDF.

    • Text in a grouped frame keeps what its paragraph styles say. A bullet list arrived without its bullets — and, invisibly, without its indents, its line spacing, its drop caps or its numbering either, because a text frame inside a group lost everything its paragraph style states from the moment the document opened. The same frame outside a group was correct, which is what made it read as a bullet problem. The same loss reached every frame in the document after the first undo, so a document that looked right on opening could quietly lose its paragraph styling the first time anything was undone. This applies to every document the moment it is opened.

    • An underline is drawn behind the text instead of over it. A style in the onboarding brochure uses a 6 pt pale pink underline as a highlighter behind the words, and it was painted after the letters and buried them — harmless at the usual hairline, and not at 6 pt. Underlines also sit where InDesign puts them now: the rule is centred on its position rather than hanging below it, so a thick one straddles the baseline instead of dropping away from it. Every existing underline moves by half its own thickness, which at the 0.5 pt default is a quarter of a point. The same change is in the PDF, so screen and print still agree.

    • Double-clicking a word in a bulleted or indented paragraph selects the word under the pointer. It selected the word to its left, by the width of the indent — visible as soon as bullets started rendering again, and true of dropped-cap paragraphs all along.

    • Text can be resized. In a converted template, selecting text and choosing a size wrote the size, stored it, showed it in the toolbar — and drew the old one. It affected exactly the text that states nothing about its own font: a heading set in a bold face could be resized, the plain body text beside it could not, and setting that text to bold and back made it resizable for good, which is the workaround that gave the cause away. Line spacing, indents and everything else set on the text were already honoured; the size was the one that never arrived.

    • The Font Manager says what is wrong with a document's fonts. Its footer now reports three faults that were previously written only to the browser's console, where nobody sees them: a font the document asks for but never loaded, a font that loaded without the measurements needed to place its first line, and a font whose file could not be fetched at all.

      The first of those is the one worth knowing about. A template whose package was exported without its fonts looks correct on the machine that made it — the browser quietly borrows the font from the operating system — while the text sits at the wrong height, and anyone opening it on a machine without that font installed sees a different typeface entirely. That combination cost a full afternoon of measuring to identify once. It now reads, in the footer:

      ⚠ "Bodega Serif" (Medium) is not among the fonts this document loaded. The browser is resolving it from the system, so it looks correct here and will be a different face for anyone without it installed. … check that the InDesign package was exported WITH its fonts.

      One fault is shown in full; several name the families, with every message on hover. A document whose fonts are all present shows nothing, as before.

    • The underline controls take the values documents actually use. A heading style in a converted brochure carries a 59 pt underline — a thick coloured bar sitting behind the words, not a line beneath them. The size field would not go above 20, so it showed 20 and reported 20 while the page drew the 59 correctly: the control disagreed with the page it was describing. Typing 40 giving 20 was the same ceiling. Size and offset now accept the range InDesign does.

      The colour well beside them shows the underline's own colour now, rather than the text's. A colour set by a paragraph style was invisible to it, so a white bar behind dark text showed a dark well.

    • A heading comes through at the size its own style sets. A style that takes its typeface from another style but sets its own size was drawn at the other style's size: a 42 pt heading came out at 53, a 20 pt one at 25. It affects any style that inherits a font and overrides the size, which is an ordinary way to build a heading hierarchy — four styles in one brochure. Two frames that reported overflowing text stopped overflowing once their headings were the right size again. This applies to every document the moment it is opened.

    • The space a paragraph style sets above and below it is honoured. A heading followed by body text had the wrong gap in every converted document, because paragraph spacing was not carried at all — it existed for tables and nowhere else. InDesign adds the space below one paragraph to the space above the next rather than merging them, and a negative space, which pulls the following paragraph up, is kept as written.

    • A bulleted paragraph can be un-bulleted. Where the bullet came from the paragraph's style rather than from the paragraph itself, switching it off did nothing at all: the setting was cleared, the style was asked again, and the bullet came straight back. Turning a list off now says "not a list" rather than saying nothing.

      A quotation in that brochure shows what this is for. Its style is a bullet list whose bullet character is a printer's opening quotation mark — that is how the designer made the quote open with a quote — and the character itself is now converted, so it arrives as the quote mark instead of a dot.

    • Text wrap standoffs are four separate distances. The gap text keeps from a picture can differ on each side — and in the project proposal it does: 6 mm at the top, 18 mm on the left, 6 mm below and right. The parameter bar had one field for all four. It showed the top distance and wrote whatever you typed to every side, so those four numbers read as a single "6.00" and the 18 was destroyed the moment anyone touched the field. There are four fields now, one per edge, each changing only its own side.

    • An element can be flipped, horizontally or vertically. Two new buttons in the toolbar, for whatever is selected — a shape, a path, a picture or a text frame. They are independent, so an element can be mirrored in both axes at once, which is the same thing as turning it half over.

      This exists because a converted brochure needed it. InDesign writes a flip as a negative scale, and there was nothing in Typograph that could hold one — so a mirrored photograph arrived as a 180-degree rotation, which mirrors both axes instead of one, and the picture came through upside down as well as back to front. Flipping is now something a document can state and something you can do.

    The conversion gives a shape back the outline InDesign drew on it. A fix in the 20260910 release stopped converted frames arriving with a black outline they were never given — and went one step too far: a shape that states its own line WEIGHT and takes its COLOUR from the object style it applies came through with a border and no colour to draw it in. That is six shapes in one brochure: polys, a rectangle and two groups, each simply invisible where InDesign draws a black line.

    An absent colour is not "no colour"; it means "ask the style I apply", and the styles disagree — InDesign's own [None] and [Normal Text Frame] say no stroke, while [Normal Graphics Frame] says black. Neither answer is right for both, so the conversion now reads what the applied style actually says, a designer's own object styles included.

    A fill stated by an object style is honoured the same way now, which no template in hand exercises but every future one might.

    A frame keeps the outline its object style gives it. The companion to the fix above: an item that states no line weight takes it from the style it applies, exactly as it takes the colour. A frame applying InDesign's [Basic Graphic Frame] — which carries a 1 pt black line — arrived with a colour and no thickness, so nothing was drawn at all.

    A master page reaches every page that applies it, at that page's position. In a fourteen-page brochure the two master spreads' contents arrived once each, at the master spread's own coordinates — so the items sat half off the page, and the second page of each master pair got nothing at all. A master spread covers two pages and is measured from the spine outwards, which is not where either of the pages it is applied to lives. The conversion now places the master's items on each page that asks for them, shifted to that page.

    A shape built from more than one outline comes through at its full size. A page-wide coloured background arrived as a 25 x 60 mm sliver in the corner of the page, because a shape whose outline is drawn in several pieces — a rectangle with a notch, a letterform, anything compound — was measured by its LAST piece alone rather than by all of them together. The shape itself was always complete; only its size and position were the last fragment's. This is not particular to master pages: any document with a compound shape in it was affected, and a background missing from a page is the visible end of it.

    A mirrored picture comes through mirrored, and the right way up. A reflection is not a rotation: half a turn mirrors both axes, so reading one as the other leaves the picture upside down as well as back to front. That is how the photograph filling two pages of the project proposal arrived. Its position and size were right all along — only its handedness was wrong, and in both axes instead of one.

    A stroke is the weight the document states. Every converted stroke was drawn a third too thick on screen. A stroke weight is measured in points, as InDesign states it and as the toolbar edits it, but the conversion wrote it in screen pixels and only the PDF read it back that way — so a 0.9 pt hairline drew at 1.2 pt while printing correctly, and a weight typed in the editor printed a quarter too thin while looking right. Screen and print now agree with each other and with InDesign, checked against the ink in the package's own PDF.

    A gradient stroke is drawn as a gradient. Four rings around the portraits in the project proposal are stroked with a gradient. Nothing in the drawing code expected a stroke colour to be a gradient rather than a flat colour, so they came through bright green — and behind that sat three more faults: the gradient's extent was discarded as the document loaded, a gradient with no stated extent collapsed to nothing, and two gradients that both arrived unnamed were taken for the same gradient, so the rings drew the wrong one's colours. All four are fixed and the rings are gold, as drawn.

    ⚠️ In print a gradient stroke is still approximated by the colour the gradient begins at. A real one has to be painted through a clip shaped like the stroke itself, which the PDF library cannot build, so this is a known and deliberate difference between screen and print for now.

    A blend mode over bare paper shows the colour it was given. A shape set to the "color" blend mode disappeared: it was blending against the white of the page, and that blend over white is white. InDesign's paper is not part of the picture — a blend mode with nothing beneath it simply shows its own colour, which is what the screen and the PDF both do now. Where there is something beneath, the blend still happens exactly as before.

    ⚠️ This changes every document that uses a blend mode, not only converted ones. multiply is unaffected, because over white it already returned the colour unchanged — which is why the others went unnoticed.

    An element that states no stroke colour does not draw one. Five shape types — triangles, tables, lines and paths among them — tested for "no stroke" in a way that could never match, so they stroked whenever a weight was set, in whatever colour the previous element happened to leave behind. A stated no-stroke is honoured now, so a few stray outlines disappear.

    An item a page has taken over from its master arrives once, not twice. Working on a master page item on the page that uses it — InDesign calls that overriding it — gives the page its own editable copy, and InDesign then stops drawing the master's. The conversion delivered both, so every such item came through twice. Where the page's copy had not been edited the two sat exactly on top of each other and nothing showed; where it HAD been edited, which is the whole point of taking it over, the master's original text stood beside the page's real text. A page that has taken over everything its master provides now gets no master group at all, which is what InDesign shows.

    Text wraps around a picture again. A picture set to push text aside came through with no wrap at all, so the text ran straight underneath it — and there were three separate reasons for that, each hidden by the one before.

    The conversion read the wrap correctly and then dropped it: a placed picture's frame is rebuilt field by field on its way into the document, and the wrap was not one of the fields copied. Not one element in the brochure carried a wrap, against the one InDesign states.

    With the wrap arriving, the text still did not move. Where a story runs through several linked frames, each frame was re-laid out around the picture but the text was never redistributed BETWEEN them, so every frame was individually correct and the page kept its old line breaks. And the composer, deciding which lines have to step around a picture, was tracking its own position down the page with an ESTIMATED line height while the page was drawn with the real one. With mixed type sizes those two drift apart — by nineteen lines into that frame the estimate was some 230 points short — so it checked for the picture well above where the text actually was, found the way clear, and stepped around nothing. Both now measure a line the same way.

    The other two reports about this picture answered themselves once the wrap arrived: the "jump object" mode was never broken, there is simply nothing in this document that asks for it.

    ⚠️ Text wrap does not see inside groups — neither a picture inside a group that should push text aside, nor a text frame inside one that should step around it. That is unchanged by this release and now filed on its own.

    Ten of the fixes in this release change what a conversion writes rather than how a document is drawn, so a template converted before it keeps what it was given until the InDesign package is converted again:

    • the outline's colour, and now its thickness, taken from the object style;
    • the space a paragraph style sets above and below itself;
    • a bullet character the style names — the printer's quotation mark among them;
    • a master page's items, on each page that applies it;
    • an item the page has taken over from its master, delivered once;
    • the size of a shape drawn from more than one outline;
    • a mirrored picture, mirrored rather than turned half over;
    • the weight of every stroke, in points rather than pixels;
    • the way text wraps around a picture frame;
    • and, from earlier in this batch, a font family whose plain face is not called "Regular".

    Everything else here is drawing, and applies to every document the moment it is opened: the heading sizes, the un-bulleting, the underline behind the text, the double-click selection, the paragraph styles in grouped frames, the text resizing, the underline controls, the flip buttons, the gradient strokes, blend modes over bare paper, the text that now steps around a picture once the wrap is there, and the stray outlines that now stay away.

    • The plain-face problem was four bugs wearing one coat, and each hid the next. The converter's PackageFontInstances::applied() asked for (family, style) and gave up, so Bodoni 72 Book was never collected and the .ttc holding all three of its faces was expanded to the one face a paragraph style named explicitly. Then resolve_font_style()'s last resorts — "the first non-italic, else the first" — answer by POSITION, and position is whichever asset the manifest registered first. Then story_style_to_font() called that resolver only inside the branch where a font_uuid outranks the family, so a run identified by family NAME never had its style resolved at all. And Font.variant_index() ends in return 0. Fixing any one of them alone changed nothing visible, which is why the first two fixes each looked like they had failed.

    • Both sides now consult the same ordered list of plain-face names — regular, book, roman, normal, medium, r — the converter deciding which face to collect and the canvas deciding which to draw. They have to agree: one collecting Book while the other still draws Bold is the state this release started in.

    • ⚠️ That list is gated on the REQUEST being a plain face too. Without the gate, asking for Black in a family holding only Bold and Book lands on Book — a heavy request answered with a light face. get_weight separates them: 'black' is 900, 'regular' is 'normal'.

    • ⚠️ "Use the family's first declared face" is the obvious fix and it is wrong. Resources/Fonts.xml does not order a family by default-ness: in the one package that produced this bug, Myriad Pro leads with Semibold Condensed and Inter with Thin Italic, and both have a real Regular that the exact lookup already finds. Taking the first would have replaced a right answer with a wrong one. There is a test for each.

    • A package can ship several faces in ONE file — a .ttc collection, which is how macOS system fonts are packaged, and Bodoni 72.ttc holds Book, Book Italic and Bold. Searching a package for *.otf and *.ttf misses it entirely, which is how this was twice diagnosed as "the font is not in the package" when it was.

    • The tab fill is the line's slack given to the tab INSTEAD of to the line's offset, not as well — both would move the line twice. Only the last tab that resolved to no stop, and only in a right-aligned paragraph: a tab that found a stop is where the document put it, a left-aligned tab should advance on the default grid, and centred is left alone because nothing has measured what InDesign does with it. Lines split around a text wrap are excluded, since their words carry explicit positions that widening a tab would not move.

    • It was measured rather than inferred, off the InDesign PDF in the package: for two separate menu rows the span from the name's left edge to the price's right edge is 57.53 mm, which is the frame's width to the hundredth of a millimetre. That is what "the tab fills the line" means, and it is not something the IDML says anywhere — the paragraph declares no tab stops at all and the separator is an ordinary tab character.

    • Written as one place_line_horizontally() rather than at the four line-push sites. The alignment offset and the fill are the same decision — where this line's slack goes — and this file has already cost two fixes that corrected one reader of a value and left its siblings.

    • The fill reaches the PDF without a change there, as the table geometry did before it: the layout emitter walks a cumulative pen over each word's own width, so a wider tab carries everything after it.

    • The bullets were not a list bug. Editor.copy() carried three of its four resolvers and dropped paragraph_props_resolver, and nothing reinstalls it — a copy goes through neither the constructor nor load_from_story(). Without it resolve_paragraph_props() returns {}, so list_type, all three indents, the style's leading, drop caps and the whole numbering group are lost at once. PageElementGroup's constructor copies every child, so a grouped frame was broken on load; a page restore copies every element, so one undo stripped the document.

    • ⚠️ That is TYP-652's shape one branch later — that was a flag a copy carried when it should not, this is a resolver a copy drops when it should. When TYP-652 was fixed nobody asked what else copy() was or was not carrying, and alignment_resolver was only on the list because TYP-673 had happened to add it. The four now live in one object that copy() assigns wholesale, and the test asserts over that object's KEYS, so a fifth resolver cannot be forgotten quietly.

    • The double-click fault was the SIXTH reader of a line's starting x. getWordLocationForMousePos() began its pen at line.offset alone, while the draw loop, the layout emitter, getPositionForCursor and findX all start at offset + left_indentfindX even carries a comment about starting after the drop cap indent so word positions come out right. Restoring the bullets only made an existing fault visible.

    • ⚠️ Six for six. Every fix in this cycle and the last has been one reader of a shared value out of step: top_ly with eight readers (twice), overflow decided in three places, alignment with five consumers, the resolvers assigned one by one, a line's x with six readers, and now the underline. The durable answer is a single accessor per value — which is why the underline became draw_word_underline() rather than a correction where it happened to be drawn.

    • The underline's order, position and weight were all measured off the package's own InDesign PDF rather than reasoned about, with the render scoped to the frame's box so no other frame's glyphs could match. Dark glyph pixels survive inside the bar, so the glyphs are on top. Both bars sat 2.975 pt above and 2.975 pt below their ink baseline for a nominal 6 pt weight, so the bar is centred on the baseline.

    • ⚠️ UnderlineOffset = 0 means the baseline itself, not the font's own underline position. The guess going in was the opposite. post.underlinePosition for that face is −2.46 pt at 20 pt, and a bar centred there would sit 0.5 pt above the baseline and 5.4 pt below it. It is not there. The offset's SIGN is still unverified — every measurement available states zero, which is symmetric.

    • The strikethrough is centred the same way but still drawn AFTER the glyphs, which is the one respect in which the two differ. "Through the middle of the x-height" is only true of a centred bar, so the automatic strikethrough needed the same change to keep meaning what it says.

    • This release needs document-generator to ship with it. The PDF implements the decoration rules a second time, and a canvas-only merge would put the underline behind the text on screen and over it in print.

    • Not a bug, and worth knowing before the next overflow report: element 20's height overflow is real. InDesign needs 36.301 mm for that frame and we need 36.266 mm — a tenth of a point apart — so the template's frame is simply too small for its text.

    • ⚠️ That took two attempts because PDFKit's characterBounds() returns a per-character METRIC box, not glyph ink: the same 25.960 pt height for every character on the line, S and p alike, so its minY is the descent line rather than the baseline. Excluding descenders does not help. Measuring a baseline off a PDF means rendering at 20–40 px/pt and taking the bottom ink row of a flat-bottomed stem — i, n, h — since round letters overshoot. Validate the IDML → PDF transform chain against a solid oval first, where no font metric is involved.

    • The size was being decided separately in each branch of the font resolver, and the branches disagreed. A run that states a font family, a uuid or a font style lands in a branch that honours its own size; a run that states NOTHING lands in the paragraph-style-font branch, which took the size from the style and never consulted the run. The order is now stated once, in resolve_run_point_size(): the run, then its character style, then whatever the branch inherits.

    • Saying it once closed two gaps nobody had reported: the font-style branch consulted the run but not the character style, and the colour-only, decoration-only, super/subscript and stroke-or-tint branches all hardcoded the element's size — so a character style that set a size and a colour lost the size.

    • ⚠️ point_size uses 0 and undefined interchangeably for "not stated" — 0 is the schema's default — so a run that states no size has to inherit rather than collapse to nothing. Every branch had that guard written out longhand. There is a test for it, and for a run size that happens to equal the inherited one, which must still count as stated.

    • ⚠️ That is the fifth property in this family: the font (TYP-660), the leading (TYP-661 and TYP-679), the alignment (TYP-673), now the size. Each was the same precedence discovered again — run override, character style, paragraph style, frame, default — and each was fixed in the branch where it surfaced. The size is the first one written as a single accessor every branch calls, which is the shape the rest should move to.

    • The font faults are DATA now, not console lines: FontManager.get_font_issues() returns {kind, family, variant, message} for each of not_loaded, no_typo_ascent and load_failed. The message is written where the fault is detected rather than in the dialog, so the footer and the console cannot drift on what a fault means — the same reasoning that put the underline geometry in a single accessor.

    • ⚠️ not_loaded is the one that hides. get_ascent_em() answers null for a family the manager never loaded, and the first baseline falls back to the measured fontBoundingBoxAscent — the hhea/win ascent that TYP-635 removed. Bodega Serif Medium states sTypoAscender 0.800 em against a win ascent of 0.960: 42 pt of drop at 264 pt, in a frame InDesign had sized to the ascent exactly. The glyphs meanwhile come from the system, so nothing about the type looks wrong. Every symptom points at geometry and the cause is a zip.

    • The report is fired at the one place the fallback happens, once per family and variant — a family can hold one face and miss another. A family that IS loaded but whose binary gave up no usable sTypoAscender stays quiet at layout time, because it was already named as it loaded, with its file; warning on every null would be two lines for one fault.

    • load_failed already reached the document's load_errors list. It is recorded as a font issue too, so all three appear in one place rather than the reader needing to know that one of them lives somewhere else.

    • Known gap: a viewer has no Font Manager, so a host loading a document with missing fonts still has nowhere to see this. get_font_issues() is the public surface a host can read; surfacing it is a decision rather than a fix, and a publishing pipeline is probably where it belongs.

    • A default is not a design, and an absent value is not a default either. TYP-671 read an absent StrokeColor as black and was fixed to read it as Swatch/None; TYP-700 is the same attribute read the other way round. The three defaults every package carries disagree — [None] and [Normal Text Frame] state Swatch/None, [Normal Graphics Frame] states Color/Black and a weight of 1 — so an item stating its own weight and no colour draws nothing under two of them and black under the third. No default is right for both, which is why ResourceMapper::extractObjectStyleColors() now resolves the applied style through BasedOn instead, and why the style's NAME is not special-cased: a designer's own object style has the same claim as a factory one.

    • ⚠️ The reasoning that let this through is in the 20260910 note, and it read as sound: "[Normal Graphics Frame] is the only default style saying black and it supplies a weight we ignore anyway, so nothing that inherits from it changes." True only for an item with no weight of its own. Five items in the Project Proposal brochure have one.

    • ⚠️ Restart the converter's messenger before testing a converter change. It is a long-running worker that loads PHP once and holds it for its lifetime — ours was 51 minutes old and still running the pre-fix code, which would have made a correct fix look like no fix at all.

    • The fly-out's numeric fields were constructed with a ceiling of 20: new Inputfield(..., 0, 20) for the weight and (..., -20, 20) for the offset. That is a CLAMP, which is why 40 arrived as 20 and −40 as −20 — and why a document's 59 pt was displayed as 20. InDesign's own ranges are 0..1000 pt for a decoration weight and −5000..5000 for its offset, and those are what the fields take now.

    • The colour well called a helper that read cRun.get_overrides()[prefix + '_color'] and nothing else, so only a colour set on the RUN was ever shown. It now prefers the colour the resolved font carries, which story_style_to_font() has already worked out through run override, character style, paragraph style and finally the text fill.

    • ⚠️ A control that re-derives what the resolved font already knows will disagree with the page — the same fault as TYP-660, TYP-661, TYP-673, TYP-679 and TYP-689, arriving from the UI side rather than the layout side. The resolved font is the one accessor that has the answer.

    • ⚠️ Worth recording because it cost a ticket: this was first filed as a resolution fault, on the reasoning that the underline must be the sixth property a paragraph style states and the text never hears. It was not — the underline had been wired through the style chain for some time, and the page was drawing 59 pt, −16 pt and white all along. A reported number that disagrees with InDesign is not evidence of a resolution fault until the drawn value has been read.

    • The heading sizes were one walk up the style chain answering for two questions. paragraph_own_font() returns the first style that states a FONT FAMILY, and the resolver took that style's point_size as well — so H3 (42, no font) drew at H2's 53, and H5 (20) at H4's 25. Which style states the font and which states the size are separate walks; paragraph_own_property() was already the generalised one and now answers both, separately.

    • ⚠️ TYP-689 touched that line in the previous release and carried the flaw across: it stated the size order once as run → character style → inherited, then passed the font-stating style's size as inherited without asking whether that was the right style to ask. Two layers of three.

    • ⚠️ Ruled out before blaming resolution, because the numbers looked like a scale (20 → 25 is exactly ×1.25, and 42 × 1.25 = 52.5 which the toolbar rounds to 53): the runs state no size, no character style is applied, and every ItemTransform in every frame's ancestor chain is identity. No frame or group scale anywhere in the document.

    • Paragraph spacing needed four layers: a field on the schema, the converter reading SpaceBefore/SpaceAfter — which it had been reading for TABLES only — the canvas applying it, and nothing at all in the PDF, which walks the emitted baselines and never computes leading itself. The gap was added to lines_y alone, the one accumulator the draw loop, the overflow test, hit-testing, caret geometry and the emitted layout all read, so it reached every one of them at once.

    • ⚠️ Whether InDesign honours a space-BEFORE at the top of a frame is unverified and deliberately not applied: no style in any of the four converted templates states one, so the conservative answer cannot move text that used to sit still. One condition to change when a document settles it.

    • ⚠️ The un-removable bullet is the same distinction as an empty text-fill swatch: a field cleared and a field set to nothing are different things, and only the second can overrule an inherited value. 'none' is now a stated list type. Expect this shape again for any paragraph property a style can carry.

    • The bullet character is in the ATTRIBUTES of IDML's BulletChar, whose text content is empty — which is why it reads as blank in a dump and looked at first like a bullet list with no glyph. UnicodeWithFont was being ignored alongside UnicodeOnly; the third type, Glyph, names a glyph by index inside a font and cannot become a character, so it stays null and the document default stands.

    • ⚠️ An absent value is not a default, for the weight as much as the colour, and the two halves were reported a release apart. Of the object styles every package carries, only [Normal Graphics Frame] states a stroke — Color/Black at 1 pt — so an item stating neither inherits both, and the frame that reported it draws a border in InDesign's own PDF: a column 100% dark over its full 661 pt height at exactly the predicted position.

    • ⚠️ A GraphicLine does not take part in that: createLineElement() overwrites the weight with its own default of 1 afterwards, because a line with no weight is nothing at all. Worth knowing before wondering why 28 weightless lines in that document were unaffected.

    • The master spread was being injected once per spread and left where the spread put it. A two-page master spans −105…105 mm and a target page is 0…210, which is the half-off-the-page symptom exactly, and the spread's second page had no group of its own at all. DocumentBuilder now computes the placements up front, injects a group per TARGET PAGE and shifts its ItemTransform by the page's offset — converted from mm to points, since the transform is in points and the page geometry is in mm. assignMasterElementsToPages(), which predated per-page injection and had no caller left, is gone.

    • ⚠️ The missing background survived that fix, which is what proved it was not a master-page fault at all: the item was arriving, at the wrong size. IdmlXmlParser calls ElementMapper::endPath() once per </PathPointArray>, and endPath() computed native_rect from the accumulated points and then cleared them — so for a PathGeometry with two GeometryPathType children the last subpath won. Rectangle u635 is 210 × 297 mm with a 25.21 × 59.65 mm second subpath, and that sliver is what converted, at the sliver's origin.

    • Filed and fixed as its own ticket rather than inside the master-page work, because it is neither about master pages nor about that document. The points still reset per subpath — endPath() adds one NativePath each and a compound poly must keep every one — so the BOUNDS got their own accumulator, unioned in endPath() and reset in mapElement(), the one place that knows a previous element is finished.

    • ⚠️ Deliberately not unioned with the element's existing native_rect. A rounded rect is given one at creation from GEOMETRICBOUNDS, which these items do not state — so that rect is zeros, and unioning would stretch every compound shape back to the origin.

    • ⚠️ One value, many readers has a mirror image: one value, many writers. Every other fix in this batch was a reader out of step with its siblings; this was a single writer running once per subpath with its reset in the wrong place, so each write was correct and the result was not. When a value is written more than once per thing it describes, ask what resets it and whether that thing knows the description is finished.

    • IDML records an override in an OverrideList on the <Page>, alternating master item and page copy — "u63b u1851 u652 u1868 …" — so the master's side is every other token starting at the first. The converter read it nowhere. Page 3 of the Project Proposal brochure overrides all eleven of its master's items and had edited none of them, which is why it looked right while page 8, which overrides four and edited them, showed both texts.

    • The join is the uuid: a master item's is already a deterministic v5 of its IDML Self, which is what lets the threading pass turn NextTextFrame="u425" into a link without a second lookup pass, and the same join answers "is this the item page 8 took over?". ⚠️ It has to be read off the ORIGINAL master elements — rewriteMasterUuids() re-salts the clones per spread before the group is built, so matching the clones finds nothing.

    • ⚠️ Top-level master items only. Overriding a frame overrides what it holds, so dropping the frame takes its image along, which is what both brochure pages need; overriding one CHILD of a master group while keeping the group is not served, and the code says so rather than appearing to.

    • ⚠️ "Where from?" was a provenance question and geometry answered it wrongly twice first. The same report — elements on a page that nothing explains — was read as a sliver at the wrong origin and as a master group at the wrong coordinates, both of which were real faults and neither of which was this one. An element you cannot place is worth asking the IDML about even after the placement looks right.

    • The flip needed two pairs of fields, not one. flip_horizontal/flip_vertical on the base element, so a shape, a path, a text frame and a picture all get it from one place, and image_flip_horizontal/image_flip_vertical for the artwork inside a frame — for the same reason image_angle already sits beside angle. InDesign transforms a frame and its content independently, and an off-centre placement makes "mirror the frame" and "mirror the picture in its box" different pictures.

    • Applied in rotate_context(), the one transform all 28 draw and hit-test paths in page.ts already call, INSIDE the rotation and about the bounding-box centre rather than rotation_cx/cy (a user can move that off the box). The PDF emits the same two operators in the same order, because cm concatenates.

    • ⚠️ Every caller of _rotate_image_context() guarded on image_angle !== 0, which skips a flipped but UNROTATED picture — the commonest case there is. Now needs_image_transform(), asked once. TcPdfGenerator had the identical guard, where it also gated StartTransform and StopTransform, and setTransform() returned early on "no angle".

    • ⚠️ The determinant separates a reflection from half a turn; the signs do not. diag(-s, -s) is both-negative with a POSITIVE determinant — a 180-degree rotation and no reflection at all. Reading it as two flips renders identically and is the original fault backwards, so an item rotated half over would arrive with two flip flags and no angle. The decomposition tests come in matched pairs for exactly this.

    • ⚠️ New schema fields must not be #[Assert\NotNull]. No document written before them states them, and a required field fails validation, upgrade and PDF generation on every existing template. The flip test pins the ABSENCE as valid, which is the part that would otherwise be discovered in production.

    • The paper is the medium, not an object. InDesign's page paper is not in the transparency group, so a blend mode with nothing beneath it returns the SOURCE colour. Both renderers painted white paper into the same surface first, and color over white is white — SetLum(anything, luminosity 1). Measured off the package's own PDF by rendering into a bitmap that was deliberately NOT pre-filled: 18671 samples came back transparent where InDesign draws nothing, and 15098 as the polygon's own 246,192,75.

    • The canvas now draws a page's elements into a transparent layer and composites it onto the paper — the isolated page group the PDF model describes — and the PDF simply stops painting the page rect. ⚠️ The layer is built only when the page is paper AND something on it blends (groups included): a real page fill IS a backdrop over the whole page, so blending against it is correct and needs no layer.

    • ⚠️ The paper question is asked at the PAGE-level caller, never inside swatchPaints(). An ELEMENT filled with Paper is opaque white and must still paint; only the page is the medium.

    • ⚠️ multiply hid that bug for years — multiply over white returns the source unchanged — which is why the two hardcoded globalCompositeOperation = 'multiply' sites never showed it, while screen, hue, saturation, color, luminosity, lighten and color-dodge were all wrong over bare paper.

    • The gradient stroke was four faults, and the conversion was not one of them. ResourceMapper parses IDML <Gradient> resources into the swatch list and gradient swatches keep their IDML Self as their uuid, so Gradient/u1d0 and all four ovals' stroke geometry were complete from the start — verified against a real converted template.

    • ⚠️ get_stroke_style() THROWS on a gradient swatch and the catch returns '#0f0'. gradient_swatch extends swatch_root and has no get_rgb_hex_string(). Ten of twelve draw sites called it, so they drew bright green and logged a swatch failure every frame. One stroke_paint() / stroke_paints() pair now, with the gradient built once — both classes that worked carried their own copy of the same fifty lines and only one handled a radial gradient with a stated start point.

    • ⚠️ stroke_paints() must resolve the swatch itself and must NEVER ask get_stroke_style(): for an empty or unresolvable uuid that method logs a load error, rewrites the element's stroke swatch to BLACK and returns black — so asking it "is there a stroke" invents one and mutates the element. An element stating no stroke swatch is the ordinary case in a converted document.

    • ⚠️ Two typos in init_page_element_general meant NO element in ANY document ever got its gradient stroke geometry back: the condition read elemen_.ative_gradient_stroke_start (no leading n), always undefined, so the else branch reset the start to (0,0) and the length to 0 every time; and the body read native_gradient_strokestart (no underscore) and would have thrown had it run. A zero-length linear gradient paints NOTHING, which is why this stayed invisible behind the green fallback.

    • ⚠️ (0,0) with length 0 is how the loader says "no geometry stated" — not null. So testing the START alone answers "it has one" and then builds an invisible gradient. Both sides now ask whether the SPAN is greater than zero, across thirteen fill sites and the stroke builder. A negative span is not a span either: IDML writes -1 for "auto".

    • ⚠️ Gradients must be added to the swatch library by uuid, not by name. addswatch() defaults to matching on the name and RETURNS THE EXISTING ENTRY, and the loader then remaps every reference onto it — so two gradients that both arrived unnamed ($ID/) collapsed into one and four ovals asking for the gold gradient were silently repointed at a white-to-black one. same_color() refuses to merge gradients at all, so two different gradients must never collapse, and a name cannot express that. Unnamed gradients are also given a name now, as the colour branch four lines above always did.

    • A stroke weight is in POINTS. native_border_size had two units at once: the converter wrote 96-dpi pixels and the PDF read them back that way, while the canvas, the toolbar and the SVG export all treated the field as points — so each renderer was right for one provenance and wrong for the other. The FONT SIZE settles it: IDML states it in points, the converter passes it through unconverted, and the canvas multiplies font size and border size by g72to96 alike.

    • ⚠️ The root cause is a naming collision worth knowing about: SizeConverter has gPixeltoMM = 25.4/72 labelled "pixel (72 dpi)" beside gPixel96toMM = 25.4/96. "Pixel" means two different units in one file, so a comment saying "native bordersize is in pixels" could not be read unambiguously by anyone. The new pointToMM() is named for the unit rather than a dpi for that reason.

    • ⚠️ Only the stroke widths moved to points. The other pixelToMM() callers convert the canvas's emitted LAYOUT geometry — baseline offsets, table cell boxes, column and row edges — which really is 96-dpi pixels. Separating those two populations was the whole of the care needed.

    • ⚠️ Five guards compared against lowercase 'none' while get_stroke_style() returns 'NONE', so they were ALWAYS true: triangles, tables, line_pods, lines and paths stroked whenever a weight was set, assigning the string 'NONE' as a colour — and Canvas2D ignores an invalid strokeStyle, leaving the PREVIOUS element's in place. They now honour a stated no-stroke, which removes strokes that used to appear in an inherited colour.

    • Text wrap was four faults in four repos, and the first three each hid the one after it. createImageElementFromPageElement() rebuilds a placed picture's frame field by field and textWrap was not among them: 0 of 252 elements carried a wrap against the IDML's single BoundingBoxTextWrap (17.008/51.024/17.008/17.008 pt = 6/18/6/6 mm). The parse was always right. The schema has always carried four standoffs and the canvas has always applied each to its own edge, so "insets not capturable" was parameterbartextwrap.ts alone — one field displaying offset.top and committing to all four sides.

    • relayout_wrap_frames() called set_size() per affected frame and stopped, so a linked chain was re-laid out and never redistributed: each frame composed around the obstruction while the chain kept its old break points. It now collects each distinct chain_root() and reflows the roots that have a next.

    • ⚠️ The one worth keeping: composition and drawing must accumulate vertical position with the same formula. place_segments() advanced y_cursor by est_leading_px(last_font) while lines_y accumulates line_leading_px() of each finished line. The estimate ignores the paragraph's stated max_leading — which TYP-661 made authoritative — and uses the current font's size where the line uses its max_font_size, so with mixed sizes the two diverge and the divergence GROWS down the frame. Measured in the browser on the real frame: 19.2 px estimated against 31.4 px real, so by line 19 the composer believed it stood at ~365 px, just above an exclusion band starting at 386, while the text was at ~597, well inside it. built_exclusions: 1, editor_exclusions: 1, lines_pushed_down: 0 — the exclusions were built and handed over correctly and consulted at the wrong height. A value that is both predicted and later computed has a reader that does not read it.

    • Two smaller ones in the same few lines, for the same reason: the band ignored para_extra_top (TYP-702 added the paragraph gap to lines_y and not to this cursor), and place_segments() mutated y_cursor without being idempotent while the paragraph loop re-places a line it has already begun — so a paragraph starting inside an obstruction skipped twice and recorded only the second skip. It subtracts wrap_extra_top before re-deriving now.

    • Known, not fixed: the skip loop advances in steps of the ESTIMATED band height and exits at the first step that clears, so it can stop one step short — line 17 of that frame lands at 615 px against a band ending at 628.3, clearing the picture's own edge at 605.6 but sitting ~13 px into the 22.7 px standoff. Text resumes ~2.5 mm under the picture where 6 mm is stated; eyeballed against InDesign and the package PDF it reads the same, so it stands. Exiting on the band's actual bottom edge would remove the last estimate from vertical placement.

    • Known, not fixed: build_wrap_exclusions() walks top-level elements only, so text wrap is group-blind in both directions. Filed separately.

    • Known, not fixed: the editor calls preventDefault() on every Cmd combination before deciding whether it handles the key, so it swallows the browser's own shortcuts — hard reload among them. The repair is small, but which keys the editor should claim is a decision rather than a bug fix.