Three things, and each one replaces a remembered number with a derived one.
The point an element turns on used to be stored, and stored numbers go stale — a rotation about a stale point is not a wonky rotation, it is the element somewhere else entirely. Text that has to fit its box could have been made to fit by rewriting its point size, and then nobody would know what the designer had chosen. How sharp an image will print was a number nothing worked out at all.
A text frame can shrink its text to fit its box. The parameter bar gains Fit text: turn it on and say which adjustments are allowed — break words, smaller type, tighter lines, tighter letters, tighter spaces — and in what order. Each one is used up before the next is reached, so "break words, then take the type down 15%, then close the lines up" is a different instruction from the same three in any other order. Drag a card to change the order.
The panel says what it actually spent: fitted: 9.60 pt, leading 92%, hyphenated.
⚠️ The fit is worked out, never written into the document. The size you set stays the size you set. That is the whole point of the feature: a host that refills a template for every order would otherwise keep the small type from whichever order happened to be running when someone pressed the button. Shorter text next time means bigger type, on its own.
⚠️ The size field keeps showing the size you set, not the size the text came out at. The fitted figure is in the Fit text panel, beside the rule that decided it — one control per question, because a size field that showed 7.5 and then stored 7.5 when you typed in it would shrink the type again on every visit.
⚠️ A frame that still does not fit keeps its overflow. Nothing is ever clipped, so the overflow
warning goes on telling the truth — an order cannot reach print quietly missing its last lines. The
panel says so in red, and validate() reports it.
⚠️ Two kinds of frame are left unfitted, on purpose: one linked to another frame, and one set to grow to fit its text. A linked frame's text belongs to the whole chain, and a growing frame is this feature's mirror image — the two would argue, and the frame's height would depend on which ran last. Both are now reported rather than silently doing nothing.
The parameter bar says how sharp an image will print. Beside the frame's name: Effective resolution: 516 dpi, in red below 150. An SVG says vector, because it has no resolution and needs none.
⚠️ It is measured against the picture, not the frame. A 300 dpi photograph cropped to a stamp is still 300 dpi; judging it by the frame would call it several thousand and wave through artwork that is genuinely soft.
⚠️ The pixel count comes from the document's image list — the original's own pixels — never from the copy the editor happens to have loaded, which is a web-sized version and would flatter the artwork.
validate() reports it too: a warning below 300 dpi and an error below 150. Placeholders and
vectors are left alone — an unfilled slot has its own warning, and a vector prints sharp at any size.
An element can turn on any of its nine box positions. The Rotate panel gains turns about, on the same nine-square control the size panel already uses for the X and Y reference point. Pick a corner and the element pivots there instead of on its middle.
⚠️ Choosing a different point does not move the element. The pivot says where the next rotation happens; it does not relocate what is already on the page. The X and Y readings do change as a result — the same as InDesign, and for the same reason: the box has moved so the artwork could stay still.
⚠️ It is a position, not a measurement. Turns on its top-left stays true after the element is resized, because the point is worked out from the box every time rather than stored once. An element that has never been given one keeps turning on its middle, exactly as before.
Images are no longer enlarged on their way in. Every uploaded image, and every image from an InDesign package, was being resized up to fill a 2560 × 1440 box if it was smaller than that — a 778 × 88 logo came out 2560 × 290. Interpolated pixels the artwork never had, at three times the bytes, and worse: an enlarged copy makes the artwork look higher-resolution than it is, which is the one direction the new resolution figure must never be wrong in.
⚠️ Images already converted keep the copies they have until they are converted again. Nothing repairs them retroactively.
An image's print copy is no longer re-compressed. One of the two places these settings were written asked for "quality 100", which is not lossless — it is a different lossy setting, and re-compressing an already-compressed photograph at it came out both larger and less faithful than leaving the encoder alone. The two copies of the settings are now one.
A POD template's images report a real resolution. The image list recorded each slot's size in millimetres where pixels were meant, so every POD image would have been reported at 25 dpi — a number describing nothing at all.
The document's image list is read again at all. The pixel size of an image has been written under one name and read under another since February, so nothing in the canvas has ever managed to read it. Nothing broke visibly — the one thing that used that value quietly fell back to something else — which is why it went unnoticed for seven months. Placeholder sizing is more accurate as a side effect.
A rotated element no longer drifts when a clip is placed. Auto-layout shifts a group into position, and that shift moved everything about the group except the point its contents turn on — so a rotated element inside a placed clip drew a long way from where it belonged. Not subtly: a turn about a point 200 mm away moves the artwork about 280 mm, which reads as the element vanishing rather than as a rotation being slightly off.
A shifted group no longer distorts the shapes in it. The same shift moved a path's or a polygon's frame and left its points behind, so the outline stayed where it was while its box went elsewhere. Both now move together, along with a picture's crop and every nested group.
A document saved with a displaced turning point draws correctly again. If an element was saved while its point had been left behind — the fault above, or the page-move fault fixed on 21 September — it drew wrongly every time the document was opened, and it drew wrongly in print too. Those documents now come up right, with no need to touch them.
Everything a host shows applies the moment it loads the new bundle — the turning point, the fit, the resolution figure and the new warnings all work on documents exactly as they are.
⚠️ Two of the fixes are in the conversion, not in the bundle. Images already converted keep the enlarged copies they were given, and a POD template already converted keeps its image sizes in millimetres. Both are corrected for anything converted from now on; existing documents need converting again to pick them up. Until they do, an image whose copy was enlarged reports a resolution flattering to the artwork rather than a low one — it errs on the quiet side, not the alarming one.
The fit is derived and none of it is on the wire. autofit — {enabled, steps[]} — is the
authored rule and travels; autofit_factor and the other three derived values do not. A stored fit
is fitted to whichever text was there when it ran, and the variable-data case is why the feature
exists.
⚠️ The size lever attaches in THREE places, not one. The assessment said
story_style_to_font() is "the single place a run's properties become a font — every call site
funnels through it". Running it against real text disproved that: the font resolver has two branches
that return the element's cached default_font directly, and the final one is the path plain
text with no run overrides takes — which is most text. So the fit reached styled runs, left ordinary
text at its authored size, and the search drove to its floor still reporting overflow because nothing
it did changed anything. autofit_font() covers the two fallbacks. A suite that asserts only "the
text fits now" tests the symptom; there are assertions on the branch itself.
Each of the other levers attaches at exactly one place, and each is where composition already
asks the question: leading at line_leading_px(), which resolves all three of its sources (a
paragraph's own value, Auto, the frame's); hyphenation at sync_hyphenation_to_editor(), OR-ed with
the authored flag so the fit can only ever turn it on; spacing on the font.
⚠️ Spacing is a DELTA in 1/1000 em, not a factor, and it is negative. Both spacings default to
zero, so a factor would scale nothing on nearly every run there is. A positive delta would add
space and make the overflow worse — a step that reliably does the opposite of fitting — so the schema
refuses it and so does the search. It uses (value / 1000) * point_size, the arithmetic this
codebase already uses for an authored spacing including its missing 72→96 conversion: a lever that
were "more correct" would mean something different from an authored −20 while claiming the same unit.
Four of the five levers are one search. Each is a scalar where a larger value takes more space
and is therefore less likely to fit, so pull_lever() + lever_range() drive all of them. Four
bespoke searches would have been four chances to get the same binary search subtly wrong.
Hyphenation needs none — it is on or off, one probe, which is why a designer usually puts it first.
⚠️ refit() runs at three moments and never per keystroke: a resize, a text change, and the
document load. The text-change moment is end_typing_run() — the burst boundary TYP-653 built —
because each probe is a linalyze() and a fit per keystroke would pay four of them per letter. A
paste is discrete and fits immediately.
⚠️ needs_composition is decided by all five values. With one lever that was the same as
checking the size factor; with five it is not, and a fit that tightened only the leading would set
nothing and go unapplied until something else forced a recomposition — the failure shape of TYP-732
and TYP-726 both.
⚠️ The size field's honesty is part of the work. authored_point_size() divides the fit back out,
and the toolbar reads through it. Without it the field reported the effective size and stored it
when typed into, so the fit shrank that again on the next visit — compounding silently, and the
TYP-701 / TYP-705 fault from a fourth direction.
Permission is text_autofit, not text_size. A host grants "you may retype this" while keeping
"and it still shrinks the way I set it". viewer_v1_defaults() withholds it: everything else it
grants is filling a template in.
v1 is the point-size lever's machine plus the other four levers; chained frames are out of scope
and validate() reports them rather than half-fitting them. document-generator needs nothing — the
emitted layout carries the effective per-word size, so print gets the fit for free.
PageElementImage.effective_resolution() answers {vector, placeholder, dpi, dpi_x, dpi_y, pixels},
or null for unknown — which is not the same as bad, and a host must not show one as the other.
Both thresholds live once in page.ts (k_effective_dpi_warning / k_effective_dpi_error) so the
panel's red figure and the validation cannot disagree about where the line is.
⚠️ Measured against native_image_rect, the picture's own rect, not the frame's. The frame crops.
⚠️ Never asset.print_size, however much it looks like the better source — it is per-URL and the
schema even models it as printSize. But imageload() writes it from the loaded image's
naturalWidth whenever the wire carried none, and what the editor loads is editor_url. Measured
across a real document it equalled naturalWidth for all seven images.
⚠️ tg_manifest.image.get_size() is the one place the wire's two spellings are reconciled.
Documents carry {width, height} (document-schema models it as a Page\Size; the converter writes
setWidth()) and this class declared {w, h} — so the field had never once been read
successfully. Measured: 7 of 7 images {width, height}, none with a w. It went unnoticed because
assetidtoasset.ts guards with size.w > 0 and falls through quietly; normalising at the boundary
repaired that reader without touching it. The stored field keeps the wire's own spelling — the
manifest is serialised by JSON.stringify, and ALLOW_EXTRA_ATTRIBUTES=false makes a renamed key a
hard validation failure. TYP-754 holds the rest.
⚠️ A vector and a placeholder are skipped, not passed with a low number. Reporting ~96 dpi for every unfilled slot — and a POD document is placeholders all the way down — would fail every POD template and teach a host to switch the check off, taking the soft photographs with it.
The upscale was in two pipelines and the settings were written twice. converter-service carried
its own copy of the editor/print/thumbnail presets while already importing ImageConversionConfig,
and that copy is the path file-service uses for every upload — so uploads were upscaled while
package conversions were not, and the print quality had drifted. It now delegates.
⚠️ Its four preset tests asserted nothing (expectNotToPerformAssertions) against a fixture
containing the word "dummy", which getimagesize() cannot read — so the clamp is skipped and the
preset never checked. That is why this was never caught.
⚠️ Each dimension is clamped independently when limiting a resize to the source: the box is a
limit, not a target, and fit: max keeps the ratio inside it. Clamping proportionally would make a
3000 × 80 panorama come out 1440 px wide.
Merge order: document-schema first (autofit on the rich element, EditOptions\Text.autofit,
and the five levers), then canvas, then editor-ui. The PHP deserialiser runs with
ALLOW_EXTRA_ATTRIBUTES=false, so a key the schema does not model fails validation, upgrade and PDF
generation.
The turning point is derived, not cached. PageElement.rotation_centre_native() is the
accessor: read the centre through it, never through native_rotation_cx/cy.
⚠️ Those two fields were a cache with many writers, and that was the whole fault. Every writer of the rect had to keep them in step, and one that forgot produced an element rotating about a point it had left — which, because a rotation about a distant centre is a translation, draws dramatically rather than slightly wrong. TYP-736 fixed one such writer in the last release; this removes the class, because there is nothing left to forget.
⚠️ set_screen_rect() is the one choke point. It turns the native centre into the screen
rotation_cx/cy that every drawing reader uses, so routing that single read through the accessor
made every rotation in the canvas correct without touching a mutator. set_native_rect(),
Scale(), move_native() and move_to_native() now write the pair only for a deliberately
placed centre.
set_native_rect()'s guard is the interesting one: it wrote back rotation_cx / factor while
set_screen_rect() had just derived rotation_cx from the stale native value, so a stale
number round-tripped through a resize unchanged. That is why last release's reporter saw moving and
rotating fix it and resizing not.
⚠️ toJSON() emits the effective centre. The pair is serialised and the PDF generator reads
it, so a derived centre on screen and a stale one on paper would rotate one element about two
different points.
⚠️ rotation_center_set is serialised and never deserialised. No loader reads it back, so
every loaded element is on the derived branch whatever the document says. The InDesign converter
does write true — on every rotated element, while storing the box's own centre — so its flag
claims "deliberate" about a value that is not, and the canvas dropping it on load is accidentally
the right outcome. Do not "fix" the deserialisation without first deciding what the flag is
supposed to mean.
offset_group_elements was redundant, not merely buggy. It was a second implementation of
PageElementGroup.move_native(), by hand and worse: it walked the children itself, moved each
rect and a picture's crop, and moved neither the rotation centre nor a path's points. Both copies —
the static one and the one on typograph_document, whose own comment says it belongs elsewhere —
are now one-line delegations. The answer to "a third forgetful writer will exist" is to stop
having a second one.
rotation_origin holds one of nine names ('' and 'c' both mean the centre, so a document
carries the field only when it changes something) and the accessor resolves it against the rect on
every read. Unlike rotation_center_set it is read back from the wire: it is authored intent
rather than a converter artefact.
⚠️ Change the origin through set_rotation_origin(), never by assignment. Swapping the pivot of
an already-rotated element displaces every drawn point by (R - I)(P - P'), which at 180° about a
40 mm box is the width of the box. The setter compensates the rect by t = (R(angle) - I)·Δ.
⚠️ Δ is how far the pivot moved inside the box, not the difference between the two absolute
pivots. The pivot is derived from the rect, so translating the rect carries the new pivot along
— solving with P' held still gives a different answer, and the two agree only at 180°, which
is exactly the angle a spot check picks. Working the algebra through with the pivot as a function
of the rect and then checking five angles numerically before writing the code is what caught it.
At 0° the correction is zero because R(0) = I, so an unrotated element needs no special case.
Only the rotation needs compensating: rotate_context() flips about the box centre rather than
the pivot, and a rect translation moves the box centre with it, so the two commute.
⚠️ The TYP-736 suite needed retargeting, and that is worth reading rather than assuming. Four of its assertions read the stored pair after moving an element whose centre is now derived, and that pair no longer updates. They were right for the previous design and now test a field nothing reads. They go through the accessor, and the suite's own docblock records that the reasoning inverted rather than that the tests were wrong — a cached value's tests and a derived value's tests measure different things on purpose.