A small release, and almost all of it is for the people building on Typograph rather than inside it: three questions a host could not ask before, and two conversion faults that were costing whole documents.
The questions came from a client building a layout feature and could each be answered with something the library already knew privately — which page is selected, which page the pointer is over, and what is wrong with this document. The conversion faults are the same shape as each other: a value with one name in one stage and another name in the next.
A host can ask which page the user picked. get_active_page() has always returned a
spread, and a spread can hold two pages — so "attach this to the page the user selected" had
no answer. There are two now, because they are genuinely different questions:
typograph.get_selected_page() is what the user picked in the page bar — the blue selection.
typograph.get_focused_page() is which page the work is on, following the selected element, and
it answers even in a viewer where there is no page bar to click.
Both return the spread and the page within it, addressed the way the library's own page move/delete calls address a page.
⚠️ A selection can legitimately be a whole spread rather than one of its pages — that is
what the editor's own delete and duplicate then act on — and get_selected_page() says so
rather than guessing a half. Documented in PageSelection.md.
A host can follow the pointer across pages. A new event, typograph_page_hover, reports
which page of which spread the mouse is over on the page preview, and fires only when that
changes. Over the grey around the paper, or the gutter between two pages, it says so.
It is switched on with set_hover_events(true) and is deliberately independent of whether the
document is editable, so a viewer embed can drive a layout picker or a page highlight
without becoming an editor.
A host can check a document before an order. typograph.validate() returns one flat list of
what is wrong: text that does not fit, an image frame with no image, an image the browser could
not load, a link that is not in the document's image list, a colour the document does not have,
a font that had to be substituted, an element entirely off the page, and an empty text frame.
Each finding says which page and which element, so a host can take the user there.
⚠️ Severity is a suggestion, not a policy. Whether overflowing text blocks a checkout is the host's decision — the library reports the facts. The same division it already uses for saving, where it says the document changed and the host decides when to save.
Before this, a host had two partial lists: the errors recorded while opening a document, and
the font faults from the Font Manager. Neither answered "is this document fit to print", and
every host assembling it separately was the argument for assembling it once. Documented in
Validation.md.
A broken image link no longer costs the whole document. An InDesign package whose links were broken before the package was made — the commonest packaging mistake there is — produced no document at all: the conversion stopped and named the links. That was already the second answer to this; the first let the file through and failed later in validation, which did not even say which image. Now the image slot arrives as a labelled placeholder, in the right place and the right size, with its frame, its wrap and its fit intact, and the rest of the fourteen pages arrive with it. The user replaces the placeholder with an upload.
⚠️ The placeholder is fetched from a placeholder service, so it needs network access wherever it is rendered, print included. And the conversion has no way yet to report what it substituted — the placeholder is visible in the document, which is the whole of the notification.
Every converted image carries the name the publisher reads. An image element's link to the
document's image list had three possible names depending on which stage wrote the file, and an
integrator could not tell which to read. Both converters wrote asset_id; only the editor wrote
key; and the PDF publisher reads key and nothing else.
So a document that went straight from conversion to print — without being opened in the editor — had no key for the publisher to resolve, no URL to fall back on, and printed a grey box in place of every image. The editor had been quietly repairing what we print, which is why nobody saw it.
Both converters now write key, with the same value as asset_id, so one field names the link at
every stage. The import step that was supposed to bridge the two names has been repaired as well:
it was written for a shape the schema revision removed and had been doing nothing since, so
templates already stored without a key are repaired the next time they are normalised.
Two of the fixes above change what a conversion writes rather than how a document is drawn:
Everything else in this release is the library's public surface and applies the moment a host loads the new bundle.
PageBar.selected already held exactly the wanted pair, {container_index, record_index}, and
record_at() already decided which half of a spread was clicked — which is the half the bar
colours. So the accessor is a read, not new modelling; the addressing is the same
logical_pages() / move_logical_page() / delete_logical_page() use.
⚠️ record_index: null is a third state, not a missing answer: the whole spread is selected.
selected_delete_count(), remove_selected() and duplicate_selected() all branch on exactly
that distinction, so collapsing it to page 0 in the public accessor would have told a host
something the editor disagrees with. null for the whole selection is a fourth state — the page
number badge activates a spread without selecting pages.
Spread.get_focused_record_index() is a separate truth with a separate meaning — it follows the
element selection rather than the page bar — and is what get_focused_page() exposes. Two
accessors because there are two questions, not to hedge.
The page hit-test is one accessor, Spread.record_index_at(), with a screen-point wrapper.
record_index_for_point() already existed and answers "which page does this element belong to":
it can never say neither, because it falls back to the nearest page's centre. That is right for
an element and wrong for a pointer, where the grey and the gutter are real places to be. The
existing method now calls the new one and keeps its fallback.
⚠️ record_index_at_screen_point() inverts the formula draw() uses for the spine lines,
view.offset_x + r.x * view.zoom * gMMtoScreen. It has to keep doing so: a hover boundary that
disagrees with the divider the user can see would be worse than no hover at all. The test
asserts the boundary is the drawn spine, from both sides.
typograph_page_hover reuses typograph_pageelement_hover's mechanism rather than adding a
parallel one: the hovered value is tracked and repeats suppressed, so it is not throttled. The
event throttle is still deliberately empty (TYP-592).
validate() composes nothing, and that is why it belongs in the canvas. A frame's overflow
state depends on its laid-out text, so answering it anywhere else means composing — the cost
TYP-653 stopped copies from paying. The open document is already composed: every frame has been
through the composer to be drawn. So this is a walk over elements. The one consequence: an
element added by API and not yet drawn has no layout and will answer that its text fits, which
is why the test suite calls redraw() before asserting.
⚠️ validate() does not replay get_load_errors(). That list is append-only and never
cleared by editing, so replaying it would keep reporting an image or font that has since been
replaced. Every finding is re-derived.
Three shaping decisions, each made to keep the list free of noise: element_off_page reports
only elements entirely outside paper + bleed (overhang is what bleed is for); an image frame
with no link is not also reported as missing from the image list; and swatch_missing is
reported per spread, because Spread.collect_swatch_uuids() is the single reader of "which
swatches does this use" — it covers every run inside every story — and a run's colour belongs to
a story that can span frames rather than to one frame.
The chain-root fix is the old lesson again: a child does not own its text (TYP-595).
text_chain_root() resolved previous_uuid against get_active_page(), so off the active
spread the walk stopped at the first step and a child answered that it was the root. It now
resolves across every spread, with a walk limit so a document whose frames point at each other
cannot hang the caller.
⚠️ A comment in page.ts about the image link has now been wrong twice — it credited an import
step with hoisting the id into key, was "corrected" to say no such code exists, and that was
wrong too. It exists and it hoists, but only from the nested asset object the TYP-577 wire
change removed, so it had been a no-op for everything we produce. Both ends are fixed; the
comment now records the history so it is not re-litigated a third time.
⚠️ The PDF publisher reads key and falls back to the element's own url. Since the converters
write no url either, the fallback was always empty — the warning it logs
("Image element has no key, using template URL directly") was the only trace, in a log nobody
reads during a conversion. Worth remembering that a fallback to a field nothing populates is not
a fallback.
The converter's placeholder machinery was already there for the images-off case, and the pod converter has always used the same service for the same reason: a POD document carries image keys and never URLs. The broken-link fix is the branch that threw now calling what the branch beside it already had — plus one extraction, because the placeholder's pixel dimensions are needed twice (in the URL that gets fetched and in the manifest size that is validated against it) and were being computed in only one of them.