The 1 s debounce lost a tab closed inside it. Writes now queue behind each
other, so an older write finishing late can neither store an older document
nor sweep the pictures a newer one added; a page revived from the
back/forward cache writes again.
A STYLEREF field's result runs are the chapter a reader shows before it
repaginates. The DOCX import dropped them and set the field's text empty, so a
header saved as .docx came back with a blank running head. ODF kept it.
Found by comparing the header and footer zones on a round trip, which nothing
did — they are documents of their own and hold what only they can. The corpus
gains two legs besides: a second save, and Word's dialect on its way out as ODT.
The corpus legs pass seven of the exporters' nineteen arguments, so the other
twelve — note configuration, page decoration, line and page numbering, the
header/footer zones, document properties — were never round-tripped from a
file. A new unit test sets all of them and reads them back out of both formats.
The kitchen sink had only ever been written, never imported; it now makes both
round trips and comes back identical. Two fixture repairs it needed: notes in
anchor order, and a section break with sections to open.
Caught: a document holding no note yet lost its note configuration on .docx.
The corpus leg out of one format and into the other compared block types and
text, so anything the trip dropped below that passed. It now compares every
attribute each node carries, with the values a format states in its own units
excluded: proportional vs absolute column widths, and a formula's inline flag,
which ODF has no place for.
Two the stricter leg caught: a note lost the paragraph style it names and fell
to the stock 10pt, and a link stored an attribute equal to its default.
A header row is w:tblHeader on the row, the same thing ODF spells with
<table:table-header-rows>. An index's leader and tab stop ride its first row,
the level styles are a family per kind, and the stop is written even with no
page number running to it. A box carries its own ring (w:bodyPr/@lIns) and
where it sits across the column when behind the text.
A section past the first now names its zones even when blank: a sectPr
without a headerReference is Word's Link to Previous, which put a chapter's
running head on the pages a blank section was meant for.
Chapter numbering rides the heading styles' w:numPr, so re-import read every
chapter as a list item — which swallowed the page break in front of it, since
the break is read for body paragraphs only. Headings now also write their own
w:outlineLvl, so a document setting its chapters in a style of its own
("Appendix 1") keeps them headings. SVG goes out rasterized: Word reads none,
and the importer makes one of every EMF too.
The corpus cross-format leg now carries the file's own stylesheet, where both
the numbering and a heading's style live; measured on one fixture, the leg
ODT → .docx → back loses 0 of 34 headings and 0 of 7 page breaks, was 7 and 7.
ODF says it with style:page-usage right/left on the section's page layout (read
only where the master is no half of a mirrored pair, which alternates by itself),
Word with a w:type oddPage/evenPage section break; both insert the blank page the
side needs, and so does pagination now — the printed number decides the side.
In the ribbon's Breaks menu under "This section". Measured against the reference
that keeps LibreOffice's own blank pages: the largest fixture 875 → 774 reports,
another 25 → 30 with its page count 27 → 29 against LibreOffice's 32.
Probed: restart the numbering at 2 on the third sheet of a mirrored document and
LibreOffice puts sheets 2 and 3 both at the outer margin with the left-page zone —
two left pages in a row. Restart at 5 and the parity stays with the sheets. Mirrored
margins, the page border and the odd/even zone now all read `printedPageNumber`.
On a fixture built for it the sheets went 15.0/30.1 → 30.1/15.0mm, LibreOffice's
30.0/15.0. One fixture rises 26 → 62 reports, all downstream of a page-count defect
there: its heads now agree with the numbers we print instead of another layout's sheets.
The inner/outer pair was read from a file, rendered and written back, but nothing
could turn it on — a book layout could only be inherited. Both margin UIs get a
checkbox beside the four fields, and while it is on the left/right fields read
Inner/Outer. A margin preset changes the values, not whether the setup is a book.
`withMirrored` keeps the key absent rather than false, so an unmirrored page setup
still compares equal. Driven headlessly: ticking it stores the flag, renames the
fields and moves an even page's text by the difference (1.5/3cm → 57px).
Where the file names no wrap side (parallel, dynamic) the importer guessed one
from horizontal-pos and then pushed it across with the frame's own x — so a
picture set two thirds into the column became a left float 9.1cm wide of its
own column, which the browser drops below the text: 64.8mm of empty page, and
everything under it followed. It reads the room on either side of the frame now.
The fixture's page then agrees with LibreOffice to 0.3mm and with its own DOCX
twin, whose count it now shares (24 against 21) — the comparison reaching 20
lines further is what its own number went up by.
"No space between paragraphs of the same style" stopped at the item a nested
list opens with: its own list-item has no previous sibling, so the walk gave up
and the space stayed. Every step of a nested procedure was ~2.5mm lower than
LibreOffice draws it, accumulating down the page. The walk climbs out of the
item and the list now, taking the nearest paragraph from whichever end it
arrives at. Fixture 85 → 68; what is left there is a line pitch of its own,
which without the extra space now costs the file a page (47 against 48).
The zone is a one-paragraph document, so a box's paragraphs had nowhere to go
and were dropped with the drawing — which took the band down with them: a
letterhead whose logo box holds two empty spacer lines came out 7.6mm short on
every page, while the same document's DOCX twin, where the converter had already
flattened the box, was right. They become lines of the zone ahead of the
paragraph that anchors it now, as that converter makes them. Fixture 48 → 44,
and the two formats agree again.
LibreOffice's compatibility option (ODF AddParaTableSpacingAtStart, Word's
w:suppressSpBfAfterPgBrk read the other way round). Off, the block that opens a
page loses its space above however the page broke — probed by flipping the flag
in one 63-page document, whose chapter heading then moved from 25.4mm to 20.0mm,
which is where the editor had been drawing every one of them. On is the default
both products write, and both formats carry the flag back out. Fixture 244 → 160.
Probed: a header of one 10pt line with a 12mm bottom margin puts the body at the
band's min-height, not 12mm lower, and a footer's trailing margin is dropped the
same way — while a margin between two of the zone's paragraphs counts in full
(two lines 12mm apart give a 20.16mm band). The collapsed zone paragraph carries
only the margins between its source paragraphs now. One 16-page fixture went
183 reports → 5, its body having sat 10.3mm low on every page.
A running head of left text, two tabs and a long title came out 17.9mm right of
where LibreOffice draws it. Three causes, all in the zone's tab pass: it measured
on the already-aligned line, so the advance it computed was self-referential; a
zone ending in a hard break had a rect on the next line, whose left edge inflated
the segment by its own offset; and a segment measured unwrapped is longer than
what the stop really aligns. Measured left-aligned now, per line, and re-measured
in the zone's own wrapping where the segment cannot reach the stop.
The edge→zone distance was document-wide, so an index whose header starts 4cm
down had its body open 14.5mm too high and the page under it followed. HfSet
carries the section's own pair (and its first page's, where the page style hands
over): ODF's page-layout margin is that distance, Word's is w:pgMar
w:header/w:footer, and both exports write it back.
The zone collapses its source paragraphs into one, and a break was pushed only
where something had already come out — so a header of three empty paragraphs
came out empty and reserved a single line. The breaks are counted from the
paragraphs now, and the strut takes the style's own size where no run can say it.
style:page-usage="right" on a single layout is a page that starts on the right,
not one half of a book, and swapping its pair drew it 9mm off. It counts as
mirrored now only where masterPageHF found the twin it alternates with. The
even-page shift is per section and per first/rest page too — a cover or an index
between mirrored pages has margins of its own and mirrors nothing.
Both importers capped every side at 10cm, which is the Layout panel's limit and
no file's: a back cover pushing its last lines to the page foot with a 21cm top
margin rendered 11cm high. fitMargins caps only what would leave the page no
strip of text at all, and the zone distances follow the page's own height.
The outline level's character style, its indent and the stop its label's tab
runs to were never read, so a 96pt grey chapter number rendered at the
heading's own size and everything under it sat ~30mm high. The label is an
inline block now — one set larger than its heading grows the line as both
products grow it — hung out of the paragraph's indent with the level's stop as
its minimum width. text-indent inherits into that block and would shrink it by
exactly what it hangs out, so the label zeroes it. Both formats carry all of it
back out (ODF's label alignment, Word's w:suff / w:ind / w:rPr).
style:dynamic-spacing="true" — what LibreOffice writes for a Word header —
makes the gap give way as the zone grows, so the band is max(height, content)
where a fixed gap gives max(height, content + gap). Probed at min-height
7.51mm, gap 6.51mm, 12pt lines: false → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6
lines, true → 7.51 / 14.60 / 29.21. The previous note said it changed neither.
Corrects two probed claims that measurement contradicted: a hard page break
does *not* swallow the block's space above (only an automatic one does), and
the header band is max(declared height, content + gap) rather than a line
count. Adds the chapter-numbering model, the mirrored master pair, and the
harness ceiling a CSS-drawn label is.
A numbered thesis lost every "1.", "2.1.4" and "A." on import: the outline
definition was read as a list wrapping headings and dropped. It is now a
document-level definition on the style sheet (format, prefix, suffix,
display-levels, start per level), drawn by CSS counters that reset down the
levels, and carried by both formats — ODF's text:outline-style, Word's
multilevel numbering bound to the heading styles. The contents rows count the
same way, since no text walk can read a CSS counter.
LibreOffice's Left Page / Right Page chain into each other through
style:next-style-name, which was read as the different-first-page idiom: one
master's geometry and zones governed the whole section, so a book-style body
lost its running head on every second page and its inner/outer margins with
it. A next-style cycle now becomes one mirrored setup — the right page's
geometry, the left page's zones as the even variant — and style:page-usage
left/right marks the margins as mirrored.
The zone box took the document's margins for its left edge, its width and
its height, so a section with page margins of its own — a mirrored body
after a wider front matter — drew its running head 9mm off the text it
belongs over, and the band was measured at the wrong width.
A roman front matter before a decimal body is one page style before another
in ODF and one w:pgNumType per section in Word, not a document setting — read
as one, its pages printed 1, 3 where the reference prints i, iii. HfSet grows
pageNumberFormat, both exports write it back, and the DOCX importer takes the
document's own format from the first section, as it already does the paper.
Odd/even is per zone in ODF: a master declaring only style:footer-left still
repeats its header on left pages, and reading the pair as one flag blanked
the running head on every even page of a mirrored document. A left element
that is present but empty still blanks its zone.
Only a break the flow had to make swallows it. Probed against the reference,
page top at 20.01mm: a block with 6/20mm above lands at 26.00/40.01 after
fo:break-before, and at 20.01 for either after an automatic break. Every
chapter opening of a book-style document is the manual case.
The body's margin has to clear the band the zone renders at, and the
estimate counted paragraphs — blind to a line that wraps, to a run smaller
than the 12pt floor, and to the zone's padding. HeaderFooterLayer now lays
each section's zones out off-screen at their text width and reports the
height; the ODF zone's own gap to the body and its padding ride the
collapsed paragraph's spacing, which is where the export already puts them.
Measured on one file: a three-line band read as two, 14.9mm a page.
The dominant master was elected after convertBlocks, so the text width a
table's margins, an inline image's fit and the tab-stop suppression measure
against was whatever master the file declares first (17.0cm against a real
14.7cm on one file). A pre-pass over the body's paragraphs elects it up
front, and the post-conversion election is gone so the two cannot disagree.
The note said a header's fo:margin-bottom just hangs into the body and does
not move it. Probed against soffice on one-page fixtures: a 20mm top margin
with a one-line 10pt header puts the body at 24.07mm with no gap and 29.07mm
with a 5mm one, three paragraphs at 37.21, a paragraph wrapping to two lines
at 33.14 (so rendered lines count, not paragraphs), and fo:min-height 20mm at
40.00 — the floor wins, the gap is not added on top. Padding counts too.
ODF's table:align="margins" means the table spans between its own margins, and
style:width is then the width the producing page had. Read as a left-aligned
table, the undeclared right margin was derived from that width against ours and
the table came out narrow — measured 12.4cm on a 14.7cm text width, its column
rule 10.4mm left of where LibreOffice draws it.
The marker pseudo is floated into the indent instead of absolutely
positioned, so a label the indent cannot hold — a "1.1.1." outline number,
a Roman numeral — moves the first line right the way both word processors
do, rather than printing over the words it labels. Out of flow either way,
so no list line grows.
The PDF clone's injected marker follows suit, and its chain now reads the
effective numbering and walks to the outermost multilevel list: a list
numbered by a named style had none of its own and printed a bare separator.
Split in two, its command half pasted straight from the clipboard and only the
caret offered the plain-text variant. The whole button now opens the menu,
whose first entry is that same straight paste.
A wrapped label kept the full 78px it was allowed to wrap at, so the
two-word labels of Review and References made every button there 90px wide
against 40–70px elsewhere. One- and two-word labels now hug their longest
line — a longer one keeps the cap, or it would break into a third line and
run out of the band. The split button's arrow follows the narrower label
instead of overlapping it, so each half tints only its own surface.
The shape trigger stood 34px tall next to the 28px colour pickers, so its
caption sat a line below theirs; every captioned picker now shares one 32px
box and the border-width field centres on it. The shape menu was absolute
inside the band, which clips its overflow and stacks below the floating box
toolbar, so it is placed from the button's rect in the top layer, as the
colour picker already does. Its caret is the same chevron as theirs.
Five legs in tests/lo-password.test.ts, driven through the repo's first UNO
helper because --convert-to cannot pass a password; self-skips without soffice
or python3-uno and runs with npm run test:lo. The probed format details live in
docs/architecture/encryption.md.
Writes agile encryption with Word's own parameters (AES-256-CBC in 4096-byte
segments, 100000 SHA-512 rounds, HMAC integrity) into a compound file built
here — cfb.ts, MiniFAT and DIFAT included. Reads that and the older standard
encryption LibreOffice writes, whose package is AES-ECB. Verified both ways
against LibreOffice.
The File menu of both chromes protects the document; the password reaches the
two write primitives in saveFile.ts, so .odt, .docx and both template kinds are
encrypted after every post-processing pass. Opening a protected file asks until
it fits or the user gives up, before any importer reads the archive. The
password is session state and never persisted; the autosaved copy stays plain.
Writes LibreOffice's whole-package form: AES-256-GCM keyed by Argon2id over
SHA-256 of the password, the deflated package as one encrypted-package entry.
Reads that plus the per-entry AES-CBC/GCM form with PBKDF2 that every earlier
version writes; the nonce sits ahead of the ciphertext, which no manifest says.
Blowfish and unknown algorithms report an unsupported-encryption error.
The caret half of the Show comments and Show revisions buttons was a 10px glyph
in the dim grey, hard to aim at and easy to miss, though the menu it opens is
the half of that control used most: there it is 16px scaled past its box, in
the text colour, and sits against the label rather than at the button's far
edge. Each half now answers its own hover a step past the wrapper's tint, so
the pointer says which of the two it would hit.
A group aligns its controls to the top: centred, a two-line label made its
button taller and pushed every one-line neighbour's icon out of the row. A run
of small buttons keeps its centred place — it joins no icon row, and at the
top it would hang over the gap below it.
Capped at 78px, so "Display for review" costs the group a column of width
instead of one of the widest buttons in the band. The caret moves up beside
the icon to free that second line, which lets the split button drop its
stacked layout and read as a row again.
ReviewMarginLayer draws the cards in a strip right of the page, at the height
of the line each belongs to, with a dashed leader running through the gap
under it. A balloon is pushed down by the one above (balloonStack, measured
per card, unit-tested) and the column belongs to its page, so it never runs
past the sheet. The strip is only reserved where the margin has something to
show; the smoke test guards a card's content against its box.
One button per kind shows or hides it and its caret says where its cards go —
balloons or the pane, which is why the two pane toggles are gone. Display for
review carries Word's four modes beside them, and the accept / reject / walk
pairs sit under each kind. The PDF path takes the mode as page attributes, so
a printout of a page in no markup reads as the page does.
CommentCard and RevisionCard hold what the pane rows carried inline, so the
margin balloon can show the same thing. The comment card gains the reply box
its thread needs and moves edit / resolve / remove behind a ⋯ menu that stays
in the flow — a balloon clips a floating popover, and three buttons in a row
cost it two lines.