Commit Graph
102 Commits
Author SHA1 Message Date
Steffen Becker 0753caa5a9 feat: placeholder fields (inline atom + ODF/DOCX round trip)
An inline atom drawn gray as ‹label›: a click selects the whole field,
typing replaces it, Tab/Shift-Tab jump between fields. Exports to ODF
<text:placeholder> (PLH sentinel U+E022) and a DOCX w:sdt content
control tagged edentext-placeholder; both importers read it back, the
export-minted gray stripped. Covered in roundtrip Leg 31 + fuzzDoc.
2026-08-24 16:16:24 +00:00
Steffen Becker 847fe59d3d feat: a frame the text runs through reserves no space at all
Word's in-front-of / behind-text (wp:wrapNone + behindDoc) and ODF's
style:wrap="run-through" both read as a side float, so a near-column-wide
picture reserved its whole height and pushed the text 23-52mm down. They
are a wrap mode of their own now: out of flow at the static position it
was anchored at, z-order from the file (inFront). Both exports carry it
back; tests/unit/wrap-through.test.ts covers the round trip.
The image fixture 72 -> 38 over both formats and 7 pages -> 6, matching
the reference; corpus 1108 -> 1074, no other fixture moved.
2026-08-17 13:45:39 +02:00
Steffen Becker ac5b05e0fe feat: an index the file writes without page numbers keeps its rows text-only
Word's TOC \n and an ODF entry template naming no index-entry-page-number both
say the rows are text alone; we drew leaders and live numbers over them either
way. Carried as the node's own pageNumbers attr, off only — the node view had
the page-less row already, for the bibliography. The exports write the switch
back out: \n over the whole level range, and the ODF template drops the tab
stop with the number, which is the pair LibreOffice's dialog switches together.
2026-08-16 23:49:53 +02:00
Steffen Becker 6987eda4fc fix: a link keeps its own paint over a baked region color
The editor's a-rule and LibreOffice's Internet Link style both outrank the
region CSS, so neither export lets the baked region color override a
non-plain link's paint: ODT leaves the color off the link run, DOCX paints
the link blue unless the run carries a direct color — the DOCX import
otherwise read its own link back as plain.
2026-08-16 13:42:30 +02:00
Steffen Becker 6789377b48 fix: DOCX carries the citation style and an all-off table look
The b:Sources part is also the citation style's only carrier, so a document
whose bibliography index sets a non-default style writes it even with no
source cited. The importer keeps a declared all-off w:tblLook ('') instead
of dropping it back to the default look.
2026-08-16 13:17:47 +02:00
Steffen Becker db3fbe32c1 fix: DOCX sections, box offsets and cell margins round clean
- a continuous break that only encodes a columns region no longer
  fabricates a section break or its own header/footer set
- a topBottom text box keeps its x offset (posOffset now written)
- cell margins read back in hundredth-cm, so twips don't show through
- a table margin's zero side stays the default instead of -0
2026-08-16 12:50:10 +02:00
Steffen Becker 205ae72ecf fix: DOCX closes round-5 gaps — XE quotes, linked revisions, box features
An XE field's literal quote is backslash-escaped (Word's own escaping;
import already unescaped it) instead of flattened to an apostrophe. A
w:ins/w:del inside w:hyperlink imports (shared handleRevision closure).
Text boxes carry charStyle (w:rStyle), index entries, citations,
date/time fields and revision marks (w:delText for deletions);
dateTimeFieldParts/xeInstr are shared with the body path.
2026-08-16 12:07:14 +02:00
Steffen Becker 8e6616c562 feat: resolved comments travel through DOCX (commentsExtended.xml)
Word keeps a comment's resolved state as w15:done in
word/commentsExtended.xml, keyed by the w14:paraId of the comment
body's last paragraph. The docx package knows neither part, so a
post-pack pass stamps the paraId, mints the part with its content
type and relationship, and the importer reads it back.
2026-08-16 11:22:21 +02:00
Steffen Becker 1de86377e5 fix: DOCX text boxes carry paragraph formatting, inline features and marks
The hand-serialized txbxContent emitted only heading style, indent and
alignment: spacing, line height, indents, direction, shading, borders,
links, comment ranges, bookmarks, cross-references, formulas, rubies
and image rotation were all dropped. A bookmark still open at a
paragraph's end lost its runs (missing flush), and the formula/ruby
run regexes could start at a box's drawing run and swallow the box
preamble — they now stay inside one run.
2026-08-16 11:21:59 +02:00
Steffen Becker 044c59d895 feat: custom note marks travel through DOCX (w:customMarkFollows)
The docx package cannot express a custom footnote/endnote mark, so the anchor
always went out as an automatic reference and the label was lost — and the
importer never read one either, so a Word file's custom mark leaked into the
note's body text. A post-pack pass now rewrites the anchor (flag + literal
run) and the note part's <w:footnoteRef/>; the importer reads the flag, takes
the run's text as the label and strips its repeat off the note body.
2026-08-16 10:30:00 +02:00
Steffen Becker 5258feb146 fix: sibling nested lists of different formats stop sharing one w:lvl
A DOCX numbering level keeps the first format it saw, so a second nested list
under the same parent with another number format or bullet char silently
rendered as the first one's (an attr-less nested list under an upper-roman
parent came back lower-alpha). The kind-mismatch fork now covers format and
bullet char too, and copies the shallower levels so the importer's cycle walk
still sees the ancestry.
2026-08-16 07:58:24 +02:00
Steffen Becker 76856f4aff fix: DOCX cell spacing accretion and nested-list kind flips
Found by the cross-format fuzz leg. importDocx baked the resolved cell
chain in as direct formatting even where it equals what an unset attr
already renders (0/0 spacing; single line height over a single default) —
every re-imported own file grew spacing attrs on every cell paragraph.
And a numbering level kept the first list kind it saw, so a nested bullet
list beside a nested ordered one came back ordered; a kind conflict now
forks its own numbering reference, as the ODT side mints a list style.
2026-08-15 22:58:29 +02:00
Steffen Becker ad2a01ba75 feat: recording revisions belongs to the document, not the editor
A new document starts with it off and an import adopts the file's own
flag, so an editor left recording no longer marks the next document it
opens. Both formats carry it, probed against soffice: ODF on the change
registry (text:track-changes, written for the flag alone where nothing
is recorded yet), Word as w:trackRevisions in settings.xml — LibreOffice
writes and reads exactly those two, and reading w:trackChanges instead
would have found nothing.
2026-08-15 17:07:06 +02:00
Steffen Becker 503c9821d6 feat: a freeform drawing keeps its own outline
A polygon, a polyline, a curve or a connector's elbow used to be dropped
with a warning; it now rides the box as an SVG path in the 0…100 box a
preset's points use, and the editor draws it. Read from all four dialects
the two products write (ODF points and svg:d, a non-primitive
enhanced-path, DrawingML custGeom, VML's reversed-case path), written
back as enhanced-path and custGeom — LibreOffice renders both exactly as
the editor draws them. An open outline is stroked, never filled.
2026-08-15 12:09:12 +02:00
Steffen Becker 9be1578b97 feat: a formula cell prints a currency and a date too
Both come from the document's language, read off Intl: the symbol's side
and spacing, the short date's order and padding. Each minted data style
names that language — without it LibreOffice prints its own locale's
separators (probed: a German document's 1.234,00 € came out $1,234.00).
A date is a serial day from LibreOffice's day 0, 1899-12-30. Word's
formula dialog offers no date, so the DOCX leg uses the field's \@
picture. Also drops a document's name from three older comments.
2026-08-15 11:44:43 +02:00
Steffen Becker c800618299 feat: a text box can run its text top-to-bottom
Word's Text Direction and LibreOffice's, on the frame's own toolbar: the
browser lays the vertical flow out, so the box needs nothing but the mode.

ODF keeps it in the frame style's *paragraph* properties — probed: in the
graphic properties LibreOffice drops it, there it keeps it and lays the box
out — and Word in the shape body's `vert`, the VML fallback's
`layout-flow:vertical` read too. Both survive a LibreOffice re-save.
2026-08-15 11:13:46 +02:00
Steffen Becker eda134f162 feat: a formula cell prints its result in its own number format
The Formula dialog gains the number format Word's has: whole numbers, two
decimals, grouped thousands and the two percentages beside the general
one.

ODF keeps it on the cell *style* as a reference to a data style — odf-kit
shares one cell style across every cell, so a formatted cell gets a copy
of it — and LibreOffice writes both back unchanged. Word keeps it on the
field as its \# switch; LibreOffice's DOCX filter neither reads nor writes
that switch, so that leg holds through our own round trip.
2026-08-15 10:53:30 +02:00
Steffen Becker 20e6dcad0e feat: a reading can be printed over its base text
Ruby, as both products' phonetic guide writes it: one element holding a
base and its reading, so the editor keeps it as one inline atom and the
browser sets it as a real <ruby>.

ODF gets <text:ruby> plus a minted ruby-family style (centred, above —
probed: LibreOffice writes that style back unchanged); Word a <w:ruby> run
from a post-pack pass, the package having none. Both legs verified through
a LibreOffice re-save, our .docx to .odt included.
2026-08-15 10:40:52 +02:00
Steffen Becker 24402dd372 feat: the bibliography gets a citation style
Cite by short name (LibreOffice's own), numbered, APA, MLA or Chicago:
the style sets the citation's label and the row's shape, and rides the
bibliography index node — ODF as the entry template LibreOffice
regenerates its rows from (verified through a re-save), Word as the
sources part's StyleName. Labels are derived and kept true by an
appendTransaction, so adding a citation renumbers the rest.
2026-08-15 10:02:54 +02:00
Steffen Becker 5f2e7fcbcf feat: a section can restart the page numbering
Word's Start at, per section: ODF writes style:page-number on the
paragraph that switches master page, DOCX a w:pgNumType w:start. That
section is no longer exported as a continuous break — probed, LibreOffice
drops the start off a continuous one, and neither word processor honours
it there. The footer counts from the nearest restart at or above the page.
2026-08-15 09:49:20 +02:00
Steffen Becker 4afd5cd1d5 feat: a paragraph can opt out of automatic hyphenation
LibreOffice's Format > Paragraph > Text Flow checkbox. Only "off"
travels (Word has no per-paragraph "on") and only under a document that
hyphenates, or it would say what is already true. ODF writes
fo:hyphenate="false" into the paragraph's text properties, DOCX gains
w:suppressAutoHyphens through a post-pack pass — the docx package
exposes no option for it. Both survive a LibreOffice re-save.
2026-08-15 01:09:55 +02:00
Steffen Becker e708744147 fix: a Word text box holding a picture keeps its caption
Its picture nests a second w:drawing, and the importer's subtree
search found *that* one's wp:inline first: the box was read as the
bare picture, so every word of the caption in it was dropped —
which is what Word's own Insert Caption writes for a floating
picture. The root is now the drawing's direct child. The export
side gains the caption's SEQ field inside a box, so the running
number stays live there too. Leg 30 covers both.
2026-08-14 22:40:42 +02:00
Steffen Becker 3b24b14470 feat: lines and arrows, which both importers used to drop
Two endpoints rather than a box: no text, no fill, and the diagonal of
the frame with `flipV` picking which one. The three kinds share their
preset names, since the heads a file declares are what tell them apart.

ODF gives a line its own element and its heads a named marker, which
needs the draw: prefix declared in styles.xml — undeclared, the file
does not parse at all. Word keeps the frame and flips it.
2026-08-14 22:11:49 +02:00
Steffen Becker 147342655d feat: sort a table, sum a column, recognise a number
The formula rides the cell, as ODF writes it; Word's field syntax is what
is stored, since it is the wider language. `ooow:` in the ODF attribute
value is a namespace prefix — undeclared, LibreOffice reads it as part of
the formula and prints "Expression is faulty".

Sort takes one key and detects the type per cell where both dialogs make
you pick. Number recognition is off by default, as it is in LibreOffice,
and a formula parses a cell's text either way — exactly as there.
2026-08-14 22:11:49 +02:00
Steffen Becker 12fe4e7239 fix: a landscape page is a landscape page in the .docx too
The `docx` package swaps the pair itself for a landscape section, so it
wants the portrait box; handed the swapped one it wrote a portrait page
merely labelled landscape — LibreOffice renders it portrait (measured:
21×29.7cm out of a landscape document).

Per-section paper rides the same fix: an HfSet carries its own format and
orientation, ODF cloning the section's page layout as it already did for
the margins and DOCX writing it into each w:sectPr.
2026-08-14 10:24:42 +02:00
Steffen Becker c38320a108 feat: citations that carry their source, and a bibliography
The citation is an inline atom holding the whole record, which is what ODF
writes on text:bibliography-mark — so no database travels beside the file.
The list is a new index family: one row per source, in document order and
without page numbers, as LibreOffice prints one.

DOCX cites by tag and keeps the sources in a custom-XML part the docx
package knows nothing about, so the post-pack pass mints the part, its
properties, both relationships and the content type itself.
2026-08-14 10:16:59 +02:00
Steffen Becker eaaec541af feat: an alphabetical index, marked word by word
References ▸ Index entry marks a place with the term the index should
list — under a key of its own where it belongs elsewhere — and the Index
in the same menu lists each term once, sorted case-insensitively, with
every page it was marked on.

Both formats write a place, not a range, so the entry is an inline atom:
ODF text:alphabetical-index-mark plus text:alphabetical-index, Word XE
plus INDEX. LibreOffice reads our file back unchanged. The bibliography,
the other half of that gap, is still open.
2026-08-14 08:44:00 +02:00
Steffen Becker d344494fcd feat: a text box keeps its lists and pictures on the way into Word
A list inside a box was flattened to literal bullet/number runs and a
picture was dropped: the box XML is a hand-built string, and neither had
the package parts it needs.

applyTextBoxesDocx already holds the whole unzipped package, so it mints
them — a media entry with its relationship, and an abstractNum/num pair
above the package's own ids, the paragraphs carrying a sentinel until
those ids exist. Verified through LibreOffice and back through our own
importer, nesting, marker symbols and start values intact.
2026-08-14 08:09:21 +02:00
Steffen Becker 4cda289272 feat: a shape can be a triangle, a star or a block arrow
Twelve presets beside the three rectangular ones. Each outline is one
polygon in utils/shapes.ts, given once: the node view draws it as an SVG
path, the ODF export scales the same points into draw:enhanced-path, and
Word gets the preset's name and draws its own geometry.

A gallery in the box toolbar and the ribbon renders its tiles from that
same table, so a new shape needs no icon. Verified through LibreOffice
from both formats; a preset we can't draw still imports as a warning.
2026-08-14 08:02:08 +02:00
Steffen Becker 5ee4eec5d7 feat: the editor records, shows and applies tracked changes
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.
2026-08-14 07:48:10 +02:00
Steffen Becker da438a7655 feat: line numbering in the left margin
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.
2026-08-14 06:56:20 +02:00
Steffen Becker 96d827b2c7 feat: page background, page border and watermark
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.
2026-08-14 06:50:25 +02:00
Steffen Becker f455a7c82f feat: a list of figures and a list of tables
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.
2026-08-14 06:29:43 +02:00
Steffen Becker 53aacd9f77 feat: captions that number themselves
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.
2026-08-14 06:21:30 +02:00
Steffen Becker 55f27da855 feat: a single paragraph can run right to left
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.
2026-08-14 06:04:12 +02:00
Steffen Becker cb1c61c900 feat: a table's header row repeats on every page
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.
2026-08-14 01:31:02 +02:00
Steffen Becker b2d1ddfb66 feat: comments, with a reviewing pane
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.
2026-08-14 01:23:54 +02:00
Steffen Becker a7092cfd1d feat: page numbering in five formats, from a start value
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.
2026-08-14 01:11:42 +02:00
Steffen Becker 46fb30249f feat: automatic hyphenation for the whole document
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.
2026-08-14 01:02:12 +02:00
Steffen Becker 98d0d5c878 feat: the document carries its title, subject, author and keywords
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.
2026-08-14 00:44:57 +02:00
Steffen Becker 06c5f2b12a fix: a cell paragraph drops the default style's space below it
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.
2026-08-13 23:39:31 +02:00
Steffen Becker 0da75ed456 refactor: the project is called edentext
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.
2026-08-13 21:43:12 +02:00
Steffen Becker 3703cea1f2 feat: notes round-trip through DOCX
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.
2026-08-13 11:42:08 +00:00
Steffen Becker 0656b606fb fix: a table's indent is measured to its text, not its edge
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.
2026-08-12 22:57:16 +00:00
Steffen Becker 29667a6f0d feat: a right-to-left page fills its columns from the right
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.
2026-08-12 22:02:36 +00:00
Steffen Becker 096de84b82 feat: mirrored page margins, so an even page is the left-hand one
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.
2026-08-12 21:50:25 +00:00
Steffen Becker f055fc4850 docs: every comment back inside the three-line rule
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.
2026-08-11 21:22:13 +00:00
Steffen Becker 95d174501d fix: a Word hyperlink keeps the look its file gives it
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.
2026-08-11 20:52:12 +00:00
Steffen Becker 860e25d8c8 fix: saving keeps a section's own page margins
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.
2026-08-11 19:35:55 +00:00
Steffen Becker 853762a774 fix: a float keeps the gap its file declares, not one of ours
`wrapDist` carries Word's distL/distR and ODF's fo:margin-left/-right onto
the frame; a file that declares none wraps flush, which is what LibreOffice
draws (probed: text runs to the frame edge, and 0.5cm of declared margin
stops it exactly 0.5cm short). The hardcoded 14px was 2px more than Word's
own 0.32cm and broke a line one word early.
2026-08-11 16:40:34 +00:00