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.
storage/markup holds the display mode (all, simple, none, original) plus, per
kind, whether it shows and where its cards go; markupView folds that into what
the page acts on. Bars, leader and both panes ask reviewItems instead of the
document, the page takes the hiding attributes, and trackChanges drops its
author decoration where the mode shows a finished text.
The thread rides the comment mark's attrs, so two runs of one comment still
merge back together and no second store is needed. ODF writes each answer as
its own annotation over the same range, naming its comment in
loext:parent-name; Word writes its own w:comment tied by w15:paraIdParent in
commentsExtended.xml (both probed). Either importer folds the answers back
into the comment they belong to, and the printed list carries them indented.
The list quoted its text but named no place, so on a five-page printout the
entry was a needle. It now leads with the page, the format LibreOffice prints
its own end-of-document comments in. Only the raster paths can say it — they
print the editor's own pagination, the vector one re-paginates in the browser.
commentsHeading gives way to commentLabels, since the page needs a word too.
The Review tab's "Print markup", on by default and stored only when off
(edentext-print-markup). Off, the raster paths drop the bar layer from the
clone and skip the comment pages, the vector path emits neither the markup CSS
nor the list.
printPdf rebuilds its options field by field, so it never saw commentsHeading
either — the printed list was headed in English whatever the UI language.
The bar in the margin beside every changed or commented block, and the comment
bodies as a list after the document — a comment's text is nowhere on the page,
so a bar alone could only say that one exists. Resolved comments print nowhere,
and a document with no markup prints exactly as before.
The print engine clips to the page area (measured), so the vector path buys the
bar's strip out of the left @page margin and gives it back as padding. The
raster paths clone the editor's own layer and append the list as whole pages.
Three pairings, the way both word processors draw them: a bar in the margin
beside every line a change or an open comment covers (ChangeBarLayer), the
reviewing pane's card marked and scrolled into view, and a dashed leader from
the text to that card (ConnectorLayer). The leader is drawn for the one at the
caret only, and dropped where a second pane sits between text and card.
commentIdAt/revisionIdAt compare ranges, not the marks at the caret: both marks
are non-inclusive, so the range a click on a card selects would match nothing.
A TOC field regenerates its rows from the styles named `toc 1`…`toc 9`, and its
cached rows are skipped on import — so those styles were never seen and the index
fell back to the editor's 0.5cm per level, dropping the file's space-after with it.
Measured on a 60-row index: a 5.1mm row pitch against LibreOffice's 6.9mm.
They now land as Contents 1…10, and the DOCX export writes the rows back under
them instead of the fallback indent.
Word caches a BIBLIOGRAPHY field's rows as a w:tbl inside the field, and
only the field's paragraphs were skipped — so the list arrived once as the
regenerated index node and once as that table. Measured on a 49-page
document: two pages of duplicate. StyleName IEEE now reads as the numbered
style it is, so its citations show [1] instead of the source key.
The clipboard carries HTML, and in an HTML string any block tag inside a
<p> closes it — so a copied box arrived as loose paragraphs. It is now
serialized as an inline span carrying its blocks as JSON, read back by a
matching parse rule; its plain text stays in the span for readers that
cannot take the attribute back.
Entries were stored as an HTML string, where any block tag inside a <p>
closes it — so a copied text box, which holds blocks, lost its place in
the line. They now keep the slice's nodes; an entry written earlier keeps
its HTML string, which insertContent takes just as well.
A box that starts its paragraph was a trap: there is no text position beside
it, so the browser pulled the caret meant for the box's own place in the line
into the frame and everything typed there landed inside it.
A frame nobody is editing is now an atom to the browser — a contenteditable
node decoration, dropped while the caret is in its text, the frame placing the
caret itself on the click that enters it. ArrowLeft/ArrowRight at the box's
first and last text position step out to it. The decoration walk enters blocks,
without which it never reached an inline box at all.
A box in the line is a character its paragraph places, as a picture is, so the
three frame-toolbar buttons were the paragraph's own alignment under a frame
icon — a second place to look for the same thing. They now show for the band
wrap alone, where nothing else can express the position.
The ordinary command needed one correction to carry that: a selected box is one
node whose range covers its own paragraphs, so aligning it moved the frame and
re-set its text in the same keystroke.
textBox becomes an inline node carrying block content, like the frame both
formats write, so "in line with text" really is in the line and a box reaches
a table cell or a list item through their paragraphs. Both exports write it
where it sits (an as-char draw:frame / a wp:inline drawing) and both imports
leave it there, which drops the hoisting machinery on either side.
Two guards the schema cannot express: no box inside a box, and a selection
reaching into a box grows to the box's bounds rather than dissolving it.
The shape body wrote spAutoFit, which LibreOffice reads as min-height 0
and then draws the box's text detached from the shape, over whatever
follows it — measured on a 90px box: the frame collapsed to one line and
its text landed 22pt below it, on the next paragraph. noAutofit is what
LibreOffice writes for the same frame; with it the text sits inside the
box at its declared height. Content taller than that is clipped, DOCX
having no fo:min-height for the ODF leg's auto-grow.