wordcraft/crates/proof
AiBizzApps 6c152055b9
Hidden text: no line breaks, hyphens, wrap areas or bookmark text from text that isn't shown (builds on #152) (#173)
* Tables: stop repeating a header row that leaves no room for the next row

A repeating header row plus a row that could not fit below it on an empty
page made place_table loop: after each page break the header was placed
again, so the page was never "at top" when the row was retried, split_row
had nothing to split, and the loop ran its 1,000 iterations per row. A
20-row table with a 640 pt header came out as 19,002 pages.

After a page break the header now repeats only when the row (or its first
split) still fits below it; otherwise the row starts at the top of the
page, as Word does when a header row would fill the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: give runs with different links or decorations their own style

Builder::style memoised StyleRuns by a key that folded strike, double
strike and "has a link" into one boolean and left out the underline
colour and baseline position. The display list reads all of these from
the shared StyleRun, so in a paragraph of like-formatted links every link
pointed at the first one's URL in the PDF (and an external link after an
internal one became a bookmark jump), double strike drew as single strike
after a struck run, and underline colours were copied.

The key is now the whole resolved format, so runs share a StyleRun only
when nothing the display list reads differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF and No Markup: leave tracked deletions out of the final text

PDF export defaults to no markup, but it laid the document out with every
deleted run in place and only switched off the strike and colour, so the
PDF printed deleted text as ordinary text ("Keep DELETEDTEXT INSERTED"):
neither the original nor the final document, and a leak of text the
author removed. The No Markup view did the same on screen.

A new LayoutOptions::hide_deleted lays deleted runs out like hidden text
(zero-width markers, so offsets, carets and hit testing keep working). PDF
export sets it whenever markup is off, the session layout sets it when
markup is hidden, and the markup toggles relayout as Show/Hide marks does.
The display list also never draws a deletion as plain text when markup is
off, whatever layout it is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tables: repeat the header on every page and put the next row under it, as Word does

The earlier fix for the runaway loop dropped the repeated header row on any
page where the header and the next row did not fit together. Word never
does that: measured on Word for Mac, it moves the row to a new page, repeats
the header there and places the row under it even when the row then runs
into the bottom margin (a 300 pt header with 400 pt rows gives one header +
row per page; a 640 pt header with 100 pt rows does the same).

place_table now remembers when the page holds only the repeated header
rows. A row that cannot split is placed there anyway instead of breaking
the page again, which is both Word's behaviour and what keeps the loop from
spinning: every pass now places something. Rows of text taller than the
room under the header still split across pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: keep tracked deletions out of bookmarks and heading tags

Without markup the PDF pages show the final text, but bookmark titles and
the titles of heading structure tags were still built from plain_text(),
which includes deleted runs. A heading "Intro SECRET Part" with "SECRET "
deleted printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and
a fully deleted heading still got a bookmark: text the author removed
leaked into the outline and the tag tree.

Paragraph::final_text() gives the text with deletions left out, and the
exporter titles headings with it unless markup is included. A heading
whose text is all deleted has an empty title and so no bookmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: deleted note references take no number and print no endnote

With deletions hidden, a tracked-deleted footnote or endnote reference
disappears from the text, but note_numbers() still counted it. Deleting
the first of two footnote references left the surviving one numbered 2,
and a deleted endnote reference still printed its endnote after the last
paragraph, because the endnote list is taken from the same map.

note_numbers() now skips references inside tracked deletions when the
layout hides them, so the remaining notes number from 1 and endnotes whose
reference was deleted are not emitted. With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: a deleted floating object leaves no wrap area behind

With deletions hidden, a tracked-deleted floating picture or shape was
not drawn, but place_para had already added its wrap exclusion from
p.objects without looking at the anchor, so the final text still flowed
around an invisible object (indented beside a square-wrapped shape).

Floating objects whose anchor is a tracked deletion are now skipped when
the layout hides deletions; deleted_objects() is shared with note
numbering, which needs the same test. With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: a deleted soft hyphen no longer breaks the word with a hyphen

With deletions hidden, a tracked-deleted soft hyphen became a zero-width
marker, but hyphenation_points() reads the paragraph's raw text and
accepted marker clusters, so the line could still break there and draw
"ab-" in a narrow column although the final text has no soft hyphen.

When the layout hides deletions, clusters inside a tracked deletion are
no longer hyphenation points (soft hyphens or dictionary points alike).
With markup shown nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* No Markup: tracked deletions add no line-break opportunities

Break opportunities came from running UAX #14 over the paragraph's raw
text, deleted runs included. With deletions hidden, a deleted soft hyphen
or space still let the line break there: "xx ab<del>SHY</del>cdef" in a
narrow column came out as "xx ab" / "cdef" instead of moving the word
"abcdef" to the next line whole.

When the layout hides deletions, the opportunities are computed over the
text without them and mapped back to offsets in the paragraph. With
markup shown, or with no deletions, the computation is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: no hyphenation points or line breaks in text that is not shown

Hidden text that is not shown (the default, and always in print and PDF)
is laid out as zero-width markers, but break opportunities came from UAX #14
over the paragraph's raw text and hyphenation_points() accepted marker
clusters, so a hidden space or soft hyphen still let the line break there:
"xx ab<hidden SHY>cdef" in a narrow column came out "xx ab-" / "cdef".
Measured in Word for Mac, the hidden soft hyphen and the hidden space are
no place to break: "xx" / "abcdef".

left_out() is now the one test for text the layout leaves out (hidden text
not shown, tracked deletions without markup). The run loop records the
ranges it leaves out, and both the break opportunities and the hyphenation
points come from that, so deletions behave exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: a floating object anchored in it leaves no wrap area behind

With hidden text not shown, a floating picture or shape anchored in a
hidden run was not drawn (the run is a zero-width marker, so no object
cluster places it), but place_para had already added its wrap exclusion,
so the text still flowed around an invisible object. Measured in Word for
Mac: a square-wrapped picture anchored in hidden text is not printed and
the text runs the full width.

deleted_objects() becomes left_out_objects(), which resolves each anchor's
formatting through the styles (hidden text often comes from a character
style) and applies the same left_out() test as the paragraph layout. Note
numbering keeps counting hidden reference marks, as Word does, so it asks
only about deletions; its behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hidden text: print the footnote of a hidden reference mark, as Word does

A footnote whose reference mark is hidden text was numbered but never
printed: the hidden run became zero-width markers without recording the
note, so place_para never put the footnote at the foot of the page.
Measured in Word for Mac with hidden text not printed, a hidden footnote or
endnote reference still takes its number (the next visible footnote is 2)
and both notes are printed. Endnotes already were, and the numbering
already matched.

A hidden reference mark now records its note like a visible one, so the
footnote goes to the foot of the page holding the line it sits on. A
deleted reference in the final text still records nothing, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: keep hidden text out of bookmarks and heading tags

The PDF never prints hidden text (its layout does not show it), but the
bookmark and heading-tag titles were built from final_text(), which drops
tracked deletions only. A heading "Intro SECRET Part" with "SECRET " hidden
printed as "Intro Part" yet bookmarked as "Intro SECRET Part", and a heading
that is all hidden text still got a bookmark for a heading nobody can see.

Paragraph::text_where() generalises final_text() to any per-run test, and
the exporter titles headings with the runs that are neither hidden (resolved
through the styles, since a character style can hide text) nor, without
markup, deleted. A heading that is all hidden has an empty title and so no
bookmark. Word's PDF export from AppleScript writes no outline, so there is
no Word answer to match; this follows what the pages show, as for deletions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PDF: titles leave out what the layout left out, table formatting included

Bookmark and heading-tag titles resolved hidden text from the paragraph
and run formatting alone, but the layout also applies the table style's
conditional formatting (header row, first column, total row) beneath the
runs. A Heading 1 in a table header row whose style hides it printed
nothing yet still got a bookmark, and a partly hidden heading leaked the
hidden part into its tag.

ParaLayout now records the byte ranges it laid out as nothing (left_out),
resolved exactly as laid out, and the exporter titles a heading from the
text outside them (plus tracked deletions without markup, as before), so
the titles cannot disagree with the pages. text_where() gives way to
text_without() over byte ranges; final_text() is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hyphenation: hyphenate words as laid out, without hidden or deleted text

Automatic hyphenation looked up the words of the paragraph's raw text and
only dropped points that fell inside text the layout leaves out. Words and
the minimum-letters limits still came from the raw text: with "accep"
hidden in "acceptable", the visible "table" could break as "t-able" (the
point after "accept"), and a hidden space split "foot<space>ball" into two
words, so the visible "football" never hyphenated.

Soft hyphens and dictionary points are now found in the same text as laid
out that the UAX #14 break opportunities already use, and mapped back to
paragraph offsets. With hidden text shown and markup on, that text is the
paragraph's own and nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout, PDF: skip left-out text in one pass, not a scan per char

Building the text as laid out tested every char against every left-out
range, and Paragraph::text_without did the same for PDF titles. Hidden
text in many runs (formatting that alternates, a common result of imports)
made both quadratic: a 100,000-char hidden heading in one-char runs took
13.9 s to convert with the release CLI, 400,000 chars minutes.

The layout now merges adjacent left-out runs as it records them and walks
the ordered ranges with a cursor; text_without sorts its ranges by start
(they may come in any order and overlap) and walks them the same way. Both
are linear: 0.20 s for that heading, 0.81 s at 400,000 chars, with the same
output. No left-out text still means no filtering at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hyphenation: check the limits of each point in constant time

hyphen_points() found each candidate point's letter run by scanning out
from the point and counted the letters on each side by rescanning the run,
and the syllable heuristic rebuilt the rest of the word as a string for
every position. Both are quadratic in the word's length. Ordinary words are
short, but with hidden separators the layout now hyphenates the visible
word, so "hyphenation " repeated 8,192 times with every space hidden is one
90,112-letter word: 2.0 s to convert with the release CLI.

Letter and uppercase counts before each char, the letter run of each char
and the heuristic's next vowel and byte offsets are now computed once, so
every point is checked in constant time: that document converts in 0.09 s.
The output is unchanged: the old functions stay in the tests as the
reference, compared over awkward words and every combination of limits, and
a hyphenated document's PDF text is identical. No maximum word length is
imposed: the work is linear, so a cap would only change output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: find the formatting of every object anchor in one walk

left_out_objects() asked props_of_char() for each floating object's anchor,
and props_of_char() scans the runs from the start, so a paragraph with many
anchors in their own runs cost a scan per anchor, quadratic, and paid it on
every layout because it runs before the paragraph cache is consulted:
40,000 hidden anchors took 0.69 s, against 0.05 s for 10,000.

The runs and the object offsets are both in order, so one walk now finds
each anchor's run, resolving a run's formatting once; past the last run it
is the paragraph mark, as props_of_char() gives. With hidden text shown and
markup on nothing can be left out and no work is done at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: find a run's style without formatting it as a string

#152 keyed each run's style by its whole format's Debug string, built for every
shaped piece; on a long document with many runs that doubled layout time (3000
paragraphs x 40 linked runs: 12.7 s vs 6.4 s for the old key). Key by a hash of
the commonly varying fields and confirm by comparing formats: 3.8 s. A capped
bucket scan keeps formats that never compare equal (NaN) linear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: floats hidden in table cells take no room, table style included

Test the layout_box path the merge extended (a float anchored in hidden text in a
table cell is not placed and leaves no wrap area), and resolve the anchors'
formatting with the table style's character formatting under the runs' own, as
the cell's text is laid out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: linear-time checks compare 8x the input against a 24x limit

At 4x with an 8x limit a busy machine could push a linear layout over the line (8.8x seen once in a full workspace run); at 8x a linear layout takes about 8x and a quadratic one about 64x, so 24x stays far from both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 08:35:35 -04:00
..
src Hidden text: no line breaks, hyphens, wrap areas or bookmark text from text that isn't shown (builds on #152) (#173) 2026-10-10 08:35:35 -04:00
Cargo.toml M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary 2026-10-05 23:38:37 -04:00