Two marks carry a revision with its author and date: an insertion is
marked text, a deletion is text kept and struck through. Consecutive
typing joins one change; accept/reject work at the cursor or on all.
ODF splits the pair — text:change-start/-end around an insertion, a
text:change where a deletion was, its text in the tracked-changes
registry. DOCX uses w:ins/w:del with w:delText. Both round-trip, and
accepting every change gives the document an unrecorded edit would.
Continuous or per page, every nth line, empty lines counted or not —
LibreOffice's Tools ▸ Line Numbering, round-tripping as its
text:linenumbering-configuration and Word's w:lnNumType. CSS exposes no line
boxes, so the layer Range-selects each block and takes the distinct tops of
its client rects as its lines; LibreOffice renders the exported file with the
numbers in the same places.
LibreOffice's Format ▸ Page Style ▸ Area/Borders and its Format ▸ Watermark,
all three round-tripping. Probed placements that are not guessable: ODF reads
a watermark only out of the master page's header (and only with xmlns:draw
declared), Word's w:sectPr wants pgBorders after pgMar or it loses the header
references, and the VML shapetype has to travel or LibreOffice cannot resolve
it. On screen the watermark is an SVG textLength, since a fontwork shape
stretches its text to its box rather than setting a font size.
The same generated index the table of contents is, over the captions of one
category instead of the headings — an `index` attr picks the family, so the
node view, the page numbers and the leader dots are shared. Round-trips as
ODF text:illustration-index / text:table-index, found by the counter's name
the way LibreOffice finds them, and Word's TOC \c "Figure" field.
A caption is a paragraph in the Caption style holding a sequence field —
one counter per category, in document order, renumbered as the document is
edited and on load (a file's cached numbers are unreadable wherever the
format is not arabic). Round-trips as an ODF text:sequence with
LibreOffice's own ooow:Illustration+1 formula and a Word SEQ field.
Caption style values probed off LibreOffice: 10pt italic, 0.212cm around.
The block's own direction, set in the Paragraph dialog where LibreOffice
keeps it, so a quoted Hebrew or Arabic paragraph turns inside a left-to-right
document. Rides ODF style:writing-mode / Word w:bidi through cells and list
items as well as top-level blocks; the direction the page already has is
inheritance, so neither importer stamps it on every block.
The structural header, beside the styling one: pageBreaks grows the
between-rows spacer by the first row's height and draws a copy of that
row — colgroup included, so the columns line up — at the foot of the
gap, which is the top of the continuation. Round-trips as ODF
table:table-header-rows and Word's w:tblHeader; LibreOffice keeps both.
Word's "don't allow row to break" needs nothing: a row that fits a page
is already an atomic leaf here.
The comment is a mark carrying its own author, date, body and resolved
flag — the bookmark pattern, so no second store and no schema node. The
pane lists them in document order and jumps to the text each marks.
ODF gets office:annotation + office:annotation-end and LibreOffice's
loext:resolved, DOCX the range pair plus word/comments.xml; a point
comment, which LibreOffice writes without a range, attaches to the run
before it. Ctrl+M no longer strips a comment, a bookmark or a link.
Layout > Page numbers: 1 / i / I / a / A and a first-page number, both
round-tripping. ODF has no document-level start — probed, LibreOffice
puts style:page-number on the first paragraph's own style — so the
export mints one there and the page layout carries style:num-format;
Word takes both in the first section's w:pgNumType. The page *count*
field stays decimal, as the one both of them write does.
A .* toggle beside match-case and whole-word takes the term as a
pattern; a pattern that will not compile simply finds nothing. Each
match keeps its captures, so the replacement expands $1…$9 and $&
the way LibreOffice does — Word has no equivalent.
Layout > Hyphenation, off by default as in both word processors. The
browser hyphenates in the document's own language (the body host gets
lang and hyphens: auto) — measured, a justified German paragraph takes
15 lines instead of 17. ODF keeps fo:hyphenate in the base style's TEXT
properties: probed, in paragraph-properties LibreOffice ignores it and
drops it on the next save. Word takes w:autoHyphenation.
LibreOffice's rule set: typographic quotes per document language, its
dash matrix, its replacement table, sentence capitalization with an
abbreviation exception list, and TWo INitials. One handleTextInput
plugin listed last, so every input rule still fires first; the rule
table itself is a pure function under test. Auto-URL and auto-list were
already TipTap's and now answer to the same checkboxes.
LibreOffice's File > Properties / Word's File > Info, reachable from
both chromes' file menus. ODF writes them into the meta.xml odf-kit
already emits (one meta:keyword each, generator EdenText); DOCX passes
them to the package's core properties. Both importers read them back,
and LibreOffice shows every field from either format.
LibreOffice's style pool, probed: Heading 6 is 12pt bold italic and
Heading 4 italic too. MAX_HEADING_LEVEL drives the extension registry,
the shortcut plugin, the TOC depth and both importers' yardstick, so
only the constant table and the CSS selector lists had to grow.
Bookmarks/cross-references move to Implemented — they already are.
Neither word processor passes it into a cell — LibreOffice puts cell text in
its Table Contents style, Word's table styles carry w:spacing after=0 — so the
space filled the cell and vertical alignment had no slack left to move the text
in, which read as the alignment being ignored. The space above is LibreOffice's
alone, so only the add model zeroes that too. Both exporters write the zeros
onto the paragraph, and only when the default style has spacing at all.
Renames the product in the docs, the page title and the DOCX creator, and moves
the whole storage namespace from odf-editor- to edentext-. The prefix is a
per-origin namespace, so it stays the product name rather than a generic one.
Word kerns nothing unless w:kern names a size to start at, and the Office
theme declares it on Title alone — but TextProps.kerning stored only the
off state, and a style inherits its parent's, so a title under a document
that kerns nothing could never turn it back on. Its 26pt line then ran
0.36mm over the text width and wrapped a word onto a second line where
LibreOffice sets it in one (measured 151.64mm kerned against 151.98).
Both states are stored now, and both formats read and write them.
A second copy of a family LibreOffice bundles makes its render
non-deterministic: it resolves the same family to either file per run — the PDF
names them Carlito and Carlito-Regular — and their vertical metrics differ, so a
page led by a large-font heading lands 1.85mm lower on some runs. Measured 5/5
identical once the duplicates were gone, against a 2-of-4 flip before, and one
fixture's count swinging 21↔62 with no code change. README now says to install
only what the local LibreOffice does not already ship.
Also skips the `~$name` lock file a word processor leaves beside a fixture it
has open, which the corpus was picking up as a document.
getClientRects()[0] gave a token that wraps — one wider than its line, or one
carrying a hyphen or a slash — its whole text at its first fragment's position,
where pdftotext reads the two fragments as separate words on separate lines. So
every such break reported a lineBreak the two engines did not actually disagree
on, and hid the case where they break at different characters. Split by the line
each character lands on; the per-character walk only runs on a word with more
than one rect.
Corpus 900 → 849, no page count moved.
Word ranks a table style's w:pPr under the paragraph style, so a style zeroing
w:after does not take the space after off a Normal that declares one — probed
against soffice, which keeps 8pt there while its w:line=240 still beats
docDefaults' 276. Read as an override, every cell paragraph lost its spacing.
Spacing in a cell now layers docDefaults ← table style ← the paragraph's own
chain (baked in, since a cell carries no style name) ← direct w:pPr.
Corpus 934 → 900; the fixture it was measured on 66 → 34, its page count exact
for the first time (50 against 50, from 44).
playwright-core resolves its own installed browser, so the hand-rolled
~/.cache/ms-playwright scan and its chrome-linux/chrome path can go — that
directory and binary name are Linux-only, and the harness threw before
rendering anything anywhere else. PARITY_CHROME still overrides.
The marker moves from a CSS ::before to a widget decoration: generated
content is no text node, so it reached neither the PDF nor a measurement.
The note also carries the paragraph style its file names, so font, size and
colour are the document's; only the hanging indent stays the editor's,
because the style model has no first-line half to pair with the margin.
Adds a unit test for the numbering and the settings, plus the architecture
doc and the two open parity findings.
Each class gets its own part via the docx package's footnotes/endnotes maps,
counted from 1, and the anchor becomes a reference run. Import reads both
parts, skipping the separator entries Word references from nowhere, and
flattens a multi-paragraph note to hard breaks. The numbering settings ride
w:footnotePr/w:endnotePr in settings.xml — a post-pack pass, as the package
exposes neither.
Anchors become an FNT sentinel and each note is hoisted to a top-level
paragraph, so its text rides every existing pass — marks, character styles,
links, bookmarks and formulas included; applyNotes cuts the region back out
into <text:note> at the anchor. styles.xml gains LibreOffice's own Footnote/
Endnote styles, the notes-configuration per class and the separator, and the
resolver reads all of it back. The importer builds the section instead of
warning that notes were dropped.
A cell's grid box claimed one row, so a w:vMerge reaching the table's last
row sat inside the banded region instead of on its bottom edge — and a band
declaring insideH nil then dropped the line. The merge extent is counted
before the covered rows are read.
Measured against LibreOffice: the bottom rule runs the full width under a
merged first column, which the editor now draws too.
The stripe began one row late: regionsAt counted the second body row as the
first band. Probed on a five-row fixture with a red band1Horz — LibreOffice
shades row 0 where the style paints no header region, and the row under the
header where it does.
The corpus cannot see this (no fixture uses the editor's own registry), so it
stayed at 739; checked by eye and by the unit test's inverted expectations. A
document with a table style assigned re-bands on its next open.
Only direct w:pPr/w:pBdr was read, so a Title style's line went missing and
everything under it sat 1.9mm high. The box now goes to the style registry,
where ParaProps' borders already render and round-trip.
Two thirds of that gap is the file's own w:pBdr w:space, carried as
ParaProps.borderPadding (ODF fo:padding) and applied to the ruled sides only —
padding the top as well pushes the paragraph's first line down.
Corpus 761 -> 739.
w:tblInd means the distance to the cell's text up to Word 2010, so a table
indented 0 hangs its left cell margin into the page margin and its first column
lines up with the body. We drew every such table 2.2mm right of LibreOffice.
Import subtracts it, marginLeft may now be negative, and both exports carry the
edge as it stands.
Word 2013 (compatibilityMode 15) redefined it as the edge — probed both ways
against soffice on one file, 29.6mm against 31.75mm. Corpus 795 -> 761.
w:tblStylePr — header row, banded rows, first/last column — was never read, so a
table styled in Word arrived as a bare grid. Import bakes each area into the cells
now (fill, borders, run marks): the file's own style has no registry entry to
re-derive regions from, and the bake is what a re-export carries.
A band's box is the whole banded region, so its insideH is the line between two
band rows; banding counts from the first body row, as in Word and LibreOffice.
Corpus 803 -> 795, page counts unchanged.
ODF style:writing-mode="rl-tb" and Word's section-level w:bidi were not
read, so a Hebrew two-column page put column 1 on the left where
LibreOffice puts it on the right — checked against pdftoppm of its own
PDF, since the harness bands both columns into one line and never gets
that far. It is a document setting now, applied as `dir` on the body
host, which is all CSS multi-column needs; it also gives bidi the base
direction the file means. Round-trips through both formats. The
header/footer band keeps its own direction, as LibreOffice lays it out.
ODF style:page-usage="mirrored" and Word's w:mirrorMargins make the
declared left/right the inner/outer pair, which an even page swaps.
Read, rendered and written back in both formats; the body rides the
per-block inset a section's own margins already use, the header and
footer band moves with it. Measured on a mirrored two-column file:
its page 2 went from 12.7-184.1mm to 31.8-203.2, which is LibreOffice's
to 0.1mm, and its running head with it.
Three measurement bugs in columnsFlow, each worth a page on its own:
rects became lines by their top, so a raised run counted as a line of
its own (192 for 111); the split probed `left + 1`, which is a line's
*end* right-to-left, so it kept a word too many and the flow then shaved
one line per pass; and one median advance under-measured a paragraph
whose lines differ in height (2834px real against 2453 estimated), by
enough to budget 74 lines into room for 64. The chain used to flip
between one fragment and two forever; it now settles after one split.
text:display="none" now drops the paragraph its style hides and the stored
value of a field that carries the attribute — LibreOffice lays out neither.
A hidden heading is the exception and still renders: it is the outline the
running head's chapter field reads, and dropping it blanked every head that
quotes one. The editor has no way to hold a block it does not draw.
ODF carries western, asian and complex-script font properties side by side and
a word processor picks by the character's script; the importer read only the
western pair, so a complex-script run came out at the document default.
Decided per run — the finest split the model has — so a document with no
complex script is untouched, and our own export (all three written alike)
round-trips. Measured: a two-column page's line pitch 7.5mm -> 5.9 against
LibreOffice's 6.0.
Brings the ribbon chrome, its tabs and controls, the Paragraph/Tabs dialogs
and the shared selection reader over from word_ui. No file overlap with
main's cover-graphic fix, so the merge is clean.
Two compounding bugs on a title/back-page background image:
- its host paragraph picks up position:relative from the line-height
rule, becoming the frame's containing block instead of the page and
offsetting it by a full page margin — now forced back to static.
- the frame always painted behind text (z-index -1); a graphic that
declares ODF's style:run-through="foreground" now paints in front,
as LibreOffice draws it, via a new inFront attr round-tripped through
ODF export/import.
Confirmed against LibreOffice's own render: the duplicated, ~2cm
displaced cover graphic now matches exactly.
Font, size, mark colour and block attributes all answer the same question — the
one value the selection agrees on, blank when it mixes. One `uniform` walk now
serves all four, framework-free, so any chrome can read it inside its own
reactive wrapper.
The classic toolbar keeps its own copies for now: it is the baseline the ribbon
is compared against, and it should adopt these only once that comparison ends.
Node 26 defines its own `localStorage` global — undefined unless the process is
given --localstorage-file — and it shadows the one jsdom installs. Every module
that reads storage at import time threw before a test could run, so 37 of 65
files failed to collect. A setup file defines the property when it is missing.
The parity findings named the six files the corpus is built from, so every
measurement carried a document title into the history. They move to a
gitignored FINDINGS.md beside the corpus itself; the README keeps the harness.
A test that needs a document builds one, so nothing under src/ or tests/
reads the gitignored corpus — CI greps for exactly that.
A sweep of the whole tree: 27 blocks ran to four lines or more. Trimmed
to what the code can't say, with two of them split back onto the
function each was really about.
A Courier New "o" was mapped to ◦ for looks. LibreOffice and Word both
draw the glyph the numbering names, so the nested lists of the gravity
summary now read like theirs.
Word paints a link through its Hyperlink character style, never for
being one, so the editor's own blue overwrote both the file's #0000FF
and the plain black of a link carrying no style at all. The DOCX
importer marks those `plain` and the CSS leaves them alone; LibreOffice
paints every ODF text:a, so those keep the visual.
Chromium colour-manages a CMYK JPEG through its embedded ICC profile
where LibreOffice and Word convert naively, so a fixture's
logo arrived dull teal on grey instead of vivid blue on black. The
importer drops the profile — measured back onto LibreOffice's own
values, and 655 KB smaller for that one logo.
Both exports wrote one geometry for the whole document, so a title page's
margins survived import and were lost on the next save. Each ODF section master
now names a page layout of its own — the document's, shifted by the section's
difference, so what the header/footer pass folded into it survives — and every
w:sectPr carries its section's w:pgMar. DOCX import takes the document pair from
the first section, the one .tiptap's padding draws.
The parity harness spread a 458-page document's word list into Math.max and blew
the stack; the longest fixture was never measured.
Three probed findings written down rather than fixed: an outside ::marker has
no metric influence in CSS (a bulleted line is 0.12mm short of LibreOffice's),
an unbreakable run longer than the line moves whole instead of breaking at the
margin, and a typeset formula keeps its own baseline rather than its frame's —
honouring the frame measured worse. Plus the live-vs-cached index numbers.
The zone is one paragraph, so its mark sizes every line of the band. Runs
that agree on a font now set that strut (applyUniformRunFont, as the body
already does), the zone schema carries blockFontSize so it renders, and
hfReachPx measures a line by the family's natural height instead of a flat
1.15. A 10pt footer reserved 12pt lines and sat 1.6mm high on every page.
A browser always kerns; ODF kerns unless told not to and Word kerns only
above the size w:kern names — nothing, in most files. Measured 5% on a
kerning-heavy string, enough to break a line a word early throughout every
Word document in the corpus. Carried on the style, since that is where
w:docDefaults and the default paragraph style put it.
ODF style:may-break-between-rows="false" — probed: LibreOffice leaves four
lines of a page empty rather than split such a table, and splits the identical
one when the attribute says "true". pageBreaks now emits a single atomic leaf
for the wrapper unless the table is taller than a page, where the rule cannot
be met. Imported, exported and round-tripped; Word has no equivalent.