Skip to content

feat(odf.js): add a real writeOdp/writeOdpContent presentation writer - #900

Merged
Mearman merged 15 commits into
mainfrom
feat/odf-odp-writer
Sep 4, 2026
Merged

feat(odf.js): add a real writeOdp/writeOdpContent presentation writer#900
Mearman merged 15 commits into
mainfrom
feat/odf-odp-writer

Conversation

@Mearman

@Mearman Mearman commented Sep 3, 2026

Copy link
Copy Markdown
Member

Scope

Builds writeOdp/writeOdpContent — the missing write half of odf.js's existing readOdpContent/readOdp — matching the shape and discipline writeOdt/writeOds already established for their own formats.

Explicitly out of scope, tracked separately:

  • .odg (drawings) — no writeOdg yet. This PR factors the shape/page writer (typed/draw/write-shapes.ts) as a shared module specifically so a future .odg writer can reuse it without reimplementing shape geometry, insets, or text/table/image content writing.
  • .sxi (OpenOffice.org 1.x presentations) — the thin wrapper that would invert transformToOoo1Package against writeOdp, mirroring how writeSxw/writeSxc already wrap writeOdt/writeOds. writeOdp is the prerequisite this unblocks (issue Add OpenOffice.org 1.x / StarOffice XML (.sxw/.sxc/.sxi/.sxd) read+write support #821); the wrapper itself is separate follow-up work.

What's built

  • writeOdpContent(document) / writeOdp(tree) — the flat and tree-level entry points, mirroring readOdpContent/readOdp.
  • typed/draw/write-shapes.ts — the shared shape/page writer. One ContentShape → a real draw:frame, handling geometry (plain svg:x/y/width/height, or svg:width/height + draw:transform when rotated — the exact algebraic inverse of the reader's resolveOdfShapeGeometry), paint order (draw:z-index), text insets (an interned graphic-family style), and content dispatch (a shape's blocks are exactly one of plain text/list paragraphs, a sole table, or a sole image — refused by name for any other combination, since a real draw:frame can hold only one of draw:text-box/table:table/draw:image, never a mix). writeDrawFrame/writeDrawShapes are the package's public surface here; the write-state constructor and planShapeContent stay internal to the module, since the only callers are this package's own writers.
  • Per-slide style:master-page/style:page-layout (a presentation genuinely allows different slides to reference different page geometry, unlike OOXML's single document-level p:sldSz).
  • Speaker notes (presentation:notes, one text:p per line), referencing a shared notes-page style:page-layout minted lazily.
  • normaliseOdpContent states the canonical round-trip form, mirroring normaliseOdtContent/normaliseOdsContent, and names every field it drops rather than leaving any silent.

Refactor alongside it: typed/odt/write.ts's own list-numId planning and paragraph/table/image canonical-form helpers were the only copies in the package; both are now shared (typed/shared/list.ts, new typed/shared/canonicalise.ts) since writeOdp needs the identical logic. Pure extraction — writeOdt's own behaviour and test suite are unchanged. Caught and fixed in the same pass: the pre-existing NO_NUM_ID_KEY sentinel was documented as NUL but was actually a literal space; it's now a genuine NUL character.

Defects found in review and fixed

  • Exponent-notation lengths (typed/shared/units.ts). formatOdfLength was a plain template-literal stringification, so any magnitude below 1e-6 came out as e.g. -7.1e-15pt. The OASIS length datatype has no exponent form, so that output was invalid ODF, and the reader discarded it silently: parseOdfTransform drops a translate() whose components fail LENGTH_PATTERN (moving a rotated shape to its own pivot), and parseBox returns undefined for an unrotated frame's svg:x/svg:y, so readDrawFrame drops the shape entirely and it vanishes from the slide. This is not an edge case: a rotated frame's translate() components are trig-derived, so a frame at the page origin cancels to rounding dust rather than a clean zero at most angles — a 100×100pt frame at (0,0) rotated 270° wrote translate(7.105427357601002e-15pt 100pt) and read back 100pt away from where it was written. Fixed on the write side (fixed-point decimal only, an exact re-spelling of the digits Number-to-string already chose, not a rounding step) rather than by widening the read-side regex, which would have kept producing lengths outside the datatype for every other consumer.
  • draw:name not entity-decoded on read (typed/draw/shapes.ts). odf.js parses with processEntities:false, so an attribute value is the literal source text; a shape named Q&A <draft> reached consumers as Q&amp;A &lt;draft&gt;. Both call sites now decode via the same decodeXmlText this reader family already uses for svg:title/svg:desc, form-control labels, and meta.xml. Pre-existing reader defect — this PR is just the first writer to emit draw:name at all.
  • paintOrder silently dropped (typed/odp/write.ts). The reader stamps paintOrder on every shape it walks, so the writer's canonical form ignoring it made every odp → odp and odg → odp conversion lose explicit z-ordering with no diagnostic. It now writes as draw:z-index — the one ODF spelling for stacking order independent of document position, already read back by paintOrderKey, and already emitted by typed/ods/write.ts's anchored frames. A value ODF can't spell (negative or fractional, which the schema permits deliberately) writes no attribute rather than being rounded onto a neighbouring shape's order.
  • The other four dropped ContentShape fields, now named rather than silent. fontScale/lineSpacingReduction are DrawingML a:normAutofit percentages — the shrink factor PowerPoint computed and stored — and ODF's own autofit vocabulary is a mode flag carrying no computed factor anywhere, so canonicalShape states the loss instead of inventing a mode from a factor and still losing the factor. sourcePath and source are dropped for exactly the reasons normaliseOdtContent already gives for the identical fields (a reader's own diagnostic path; quarantined, opaque residue).

Verification

1. Self-consistency (round-trip law). write.test.ts pins the XML shapes construct-by-construct; write-round-trip.test.ts states and checks normaliseOdpContent(readOdpContent(writeOdpContent(document))) === normaliseOdpContent(document) across metadata, multi-slide/multi-page-size documents, formatted runs and whitespace, nested bullet/ordered lists (including two shapes sharing one raw numId without merging), a table and an image each as a shape's sole content, insets, multi-line notes, residue dropping, and every refusal. Rotated-shape geometry is checked with an explicit numeric tolerance rather than the blanket equality helper, since two independent trig evaluations on either side of a real round trip aren't guaranteed bit-identical (the inverse itself is exact algebra, not an approximation).

Two genuine writer bugs were caught here before they shipped: an image's own widthPt/heightPt must become the enclosing shape's frame size on read-back, since ODF's draw:image has no size of its own inside a draw:frame; and the exponent-notation length above, now pinned by a sweep of 15 rotation angles across 7 frame boxes — several at or centred on the page origin — asserting every shape survives with its geometry intact, plus an unrotated frame whose own svg:x/svg:y reach exponent magnitude (which, before the fix, disappeared from the slide entirely).

2. Real, independent implementation (LibreOffice 26.2.5.2). A sample .odp covering multiple slides (one widescreen, one A4-portrait), a shape with mixed bold/italic/plain runs and centred alignment, rotated shapes both away from and at the page origin, shapes whose paint order disagrees with their document order, a draw:name carrying an ampersand and angle brackets, a nested bullet list, a shape carrying a table (with a merged cell) as its sole content, a shape carrying an image as its sole content, and multi-line speaker notes:

soffice --headless --convert-to fodp sample.odp   # flat XML, for content inspection
soffice --headless --convert-to pdf sample.odp    # rendered pages, for visual inspection

Both exit 0 with no error, and xmllint --noout accepts every written part. Every authored string appears verbatim in LibreOffice's re-serialised flat XML — all 3 draw:pages, the table:table, both draw:transforms, both text:lists, the escaped draw:name, and every string of text. That is a content check, not a byte-identity one, and could not be either: --convert-to fodp re-serialises the whole document through LibreOffice's own writer, which renames styles, reorders attributes and adds defaults of its own. The rendered PDF (3 pages) visually confirms the bold/italic mixed formatting, the centred title, the nested bullets, both rotated shapes sitting where their frames put them, the table with its merged cell, and the A4-portrait slide's own different geometry.

draw:z-index is honoured: slide 1's shapes were written in an array order deliberately unlike their own paintOrder (3, 1, 0, 2), and LibreOffice re-emitted them in paintOrder order, dropping the attribute and physically reordering the elements instead — the mirror image of what the reader's own draw:z-index note already documents finding in LibreOffice-authored files.

One gap found and fixed: an earlier version wrote presentation:notes with no style:page-layout-name at all. Real LibreOffice output always states one; writeOdp now mints a shared notes-page layout lazily.

A second gap found and fixed — an undeclared namespace prefix. Speaker notes initially arrived on the slide itself rather than its notes page, which looked like LibreOffice's own AutoLayout placeholder-matching declining to bind a minimal presentation:notes/draw:frame. It wasn't: presentation:notes and its frame's presentation:class are the only presentation:-prefixed names any writer here emits, and package-io/scaffold.ts's shared prefix list never declared that prefix, so the part was not namespace-well-formed XML at all (xmllint --noout content.xml: Namespace prefix presentation on notes is not defined). LibreOffice imported it anyway, treated the unrecognised element as ordinary slide content, and re-homed its text onto the visible shape list. With the prefix declared, --convert-to fodp round-trips the notes inside presentation:notes where they were written, and --convert-to pdf renders no notes text on the slide page. src/package-io/namespace-declarations.test.ts now audits every prefix each writer emits against what that part's root declares, so the next one fails a test instead of shipping.

Worth knowing about what a green soffice run does and doesn't prove: LibreOffice's own length parser accepts values outside the OASIS length datatype, so the exponent-notation lengths above converted cleanly and landed in the right place. The verification pass could not have caught that defect — only odf.js's own spec-conforming reader did. It's pinned by unit tests rather than by a soffice run for exactly that reason.

3. Full check suite, green from a forced (uncached) run: pnpm exec turbo run _lint _typecheck _test _test:workers --filter=odf.js --force (1144 tests) and a full-workspace pnpm exec turbo run _lint _typecheck --force (57 tasks).

Reviewer checklist

  • The rotation-geometry algebraic inverse in typed/draw/write-shapes.ts's frameGeometryAttrs (worth checking the derivation against resolveOdfShapeGeometry directly).
  • canonicalShape's stated losses (typed/odp/write.ts) — particularly the claim that ODF has no target for fontScale/lineSpacingReduction, which is the one judgement call rather than a forced fact.
  • Whether omitting draw:z-index for a fractional/negative paintOrder (rather than refusing the write) is the right call.
  • The typed/shared/list.ts/typed/shared/canonicalise.ts extraction — confirm it's genuinely behaviour-preserving for writeOdt (the full pre-existing odt test suite passes unchanged, but worth a second look given it touches shared infrastructure).

Independent adversarial review, second round

A final independent pass (against c8d74f5a, after S1-S4 above already landed) found three real, narrower problems and confirmed everything else holds:

  • odfZIndexOf accepted any Number.isInteger paintOrder, but Number.isInteger(1e21) is true and String(1e21) is "1e+21" — the identical exponent-notation defect this same branch had just closed for lengths, reintroduced for draw:z-index. Fixed by tightening the guard to Number.isSafeInteger, with a regression test.
  • Half of the draw:name decoding fix (S3) had zero test coverage: mutating the odg custom-shape call site (readCustomShapeAsTextShape) back to a bare attrValue left the entire suite green. Added a test that fails on that exact mutation.
  • canonicalShape's own "nothing dropped silently" comment said "THE THREE FIELDS" while naming four, and never mentioned a fifth (frames) it also drops — corrected to name all five, matching normaliseOdtContent's existing precedent for the identical fields.

All three verified by reverting each fix individually and confirming the corresponding new test fails, then re-passing with the fix restored. Full check suite green from a forced run afterward (_lint _typecheck _test _test:workers --filter=odf.js --force, plus a full-workspace _lint _typecheck --force).

@Mearman
Mearman marked this pull request as ready for review September 3, 2026 21:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review ⚠️ Failed 2026-09-03T21:45:16.977268Z 3b3f024 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…form helpers

typed/odt/write.ts's own numId-run-planning (ListPlanState/listKindOf/canonicalNumId)
and its paragraph/table/cell/image canonical-form helpers were the only writer of
either kind in the package, so both lived as private functions in that one file.
A second writer needs the identical logic verbatim: list runs group and mint
canonical numIds the same way regardless of which container holds the text:list,
and writeOdfTable's own table/cell/image round-trip shape does not change
depending on which writer calls it.

Move the numId planning into typed/shared/list.ts (ListPlanState,
planListMembership, closeListPlan, listKindOf, canonicalNumId, NO_NUM_ID_KEY)
and the canonical-form helpers into a new typed/shared/canonicalise.ts
(canonicalColor, canonicalRun, canonicalParagraph, canonicalTable,
canonicalImage), with typed/odt/write.ts importing both rather than declaring
them locally. Pure extraction: normaliseOdtContent's own behaviour and every
existing odt test are unchanged.

The extracted NO_NUM_ID_KEY sentinel is now a genuine NUL character, matching
its own comment ("NUL -- forbidden in well-formed XML 1.0 content") and
registry.ts's identical FINGERPRINT_SEPARATOR convention -- the pre-existing
local copy was a literal space, which the comment never actually described.
typed/draw/shapes.ts's own readDrawFrame/walkDrawShapes are already shared
between readOdp and readOdg -- their write-side mirror belongs in the same
place, so a future odg writer reuses shape geometry, insets, and text/table/
image content writing rather than reimplementing it.

writeDrawFrame turns one ContentShape into the draw:frame element the reader
reads back: svg:x/y/width/height when unrotated, or svg:width/height plus a
draw:transform="rotate(...) translate(...)" when rotated -- the exact algebraic
inverse of typed/shared/transform.ts's resolveOdfShapeGeometry, derived by
solving that module's own center/rotationDeg formulas for the translate()
offset a given frame+rotationDeg requires. planShapeContent validates and
discriminates a shape's blocks into the one content kind a real draw:frame can
carry (table:table XOR draw:text-box XOR draw:image -- never a mix, since ODF
has no spelling for one), refusing a heading, a page break, an embedded
object, a construct boundary marker, or a mixed table/image by name rather
than silently dropping it, and canonicalises any paragraph-level list
membership onto the caller's own ListPlanState in the same pass. writeDrawShapes
writes a whole page's shapes in document order.

Not yet exported from the package barrel or wired into any writer -- the odp
writer that actually uses this module lands as its own commit.
The inverse of typed/odp/read.ts, and this package's third content writer
(typed/odt/write.ts's own top-of-file note states the shared discipline every
writer in this family follows). writeOdpContent takes the flat 'presentation'
ContentDocument readOdpContent returns and produces a real .odp Package;
writeOdp does the same from the DocumentTree readOdp returns, via flattenTree.

A presentation has no office:text body flow at all -- one style:master-page/
style:page-layout pair is minted per slide (a presentation genuinely allows
different slides to reference different page geometry, unlike OOXML's single
document-level p:sldSz), and each draw:page's own shapes are written by
typed/draw/write-shapes.ts's writeDrawShapes. Speaker notes write as
presentation:notes, one text:p per line, referencing a page-layout of their
own (minted lazily, the first time any slide actually has notes) -- real
LibreOffice output always states one, since a notes page is sized for
printing independent of its slide's own on-screen size. normaliseOdpContent
states the canonical form a written-and-reread document equals, including the
one fact ODF forces rather than this writer choosing it: an image's own
widthPt/heightPt become its enclosing shape's own frame size, since a
draw:image has no size of its own inside a draw:frame.

A slide's own residue (transition/animation/sound facts) is dropped, the same
deliberate exception writeOdt makes. .odg and the .sxi OpenOffice.org 1.x
wrapper are not covered here -- both are separate, tracked follow-up work
built on top of what this module and typed/draw/write-shapes.ts establish.
write.test.ts pins the construct-by-construct XML writeOdpContent actually
emits (package structure and media type, one style:master-page/page-layout
per slide, unrotated vs rotated shape geometry, the graphic-family inset
style, text/table/image shape content dispatch, list grouping, and speaker
notes) -- the same role typed/odt/write.test.ts plays for writeOdtContent,
proving the output is the ODF a real consumer expects rather than merely
something this package's own reader happens to agree with.

write-round-trip.test.ts states the law itself:
normaliseOdpContent(readOdpContent(writeOdpContent(document))) equals
normaliseOdpContent(document), covering metadata, multi-slide/multi-page-size
documents, formatted runs and whitespace, bullet/ordered lists (including two
shapes sharing one raw numId without merging their runs), a table and an
image each as a shape's sole content, multiple shapes with insets, multi-line
notes, residue dropping, and refusals (a page break, a mixed table, a
heading). Rotated-shape geometry is checked separately with an explicit
numeric tolerance rather than the blanket equality helper every other case
uses, since two independent trig evaluations on either side of a real write-
then-read round trip are not guaranteed bit-identical -- typed/draw/
write-shapes.ts's own frameGeometryAttrs is an exact algebraic inverse, not an
approximation, but ordinary IEEE-754 rounding still applies to a real round trip.

This suite is what caught the one genuine writer bug fixed in the previous
commit: an image's own widthPt/heightPt, left to pass through verbatim in the
canonical form, must instead become the enclosing shape's own frame size,
since ODF's draw:image carries no size of its own at all.
…tion

Status and Writing-a-document now cover writeOdp/writeOdpContent the same way
they already cover writeOdt/writeOds -- what round-trips, what is refused by
name, and where the shared shape writer (typed/draw/write-shapes.ts) sits so
a future odg writer knows to reuse it. The Architecture section's own module
list is updated to match (typed/shared/list.ts and canonicalise.ts as the
write-side helpers odt and odp now share; typed/draw/write-shapes.ts beside
shapes.ts).

A new LibreOffice verification subsection records the real, independent-
implementation check this PR's own bar requires: the exact soffice --headless
commands run against a sample .odp covering multiple slides, mixed
bold/italic/aligned text, a rotated shape, a nested bullet list, a table with
a merged cell, an image, and multi-line speaker notes, plus what was found --
one real gap (a missing style:page-layout-name on presentation:notes) that
got fixed, and one found, precisely characterised, and left open (LibreOffice's
own undocumented AutoLayout placeholder-binding does not recognise this
writer's minimal notes placeholder, though the notes text itself is never
lost and the XML is well-formed per the OASIS schema).

The .sxi/.sxd notes in the OpenOffice.org 1.x section are corrected: .sxi now
has writeOdp to build its own writer against (tracked separately, not done
here), and only .sxd still needs a writeOdg underneath it first.
…en part

writeOdp emits presentation:notes and presentation:class, but the shared
prefix list in package-io/scaffold.ts declared no presentation: prefix, so
every .odp carrying speaker notes was not namespace-well-formed XML.
xmllint rejects it outright ("Namespace prefix presentation on notes is not
defined"); LibreOffice instead imported the file with the notes element
unrecognised and re-homed its text onto the slide's own visible shape list,
so notes rendered on the slide and the notes page came back empty.

That symptom was previously recorded here as an AutoLayout placeholder
heuristic in LibreOffice, in writeSlideNotes's own comment and in the
README. It was a missing xmlns declaration; both now state the real cause.

Nothing between a writer and the emitted bytes checks that a qualified
name's prefix is bound, and this package's own reader matches prefixes as
plain strings, so the defect round-trips perfectly and only a real consumer
sees it. namespace-declarations.test.ts closes that structurally, walking
every XML part each writer produces and asserting every prefix used in an
element or attribute name is one the part's own root declares.
…Color objects

The new namespace-declarations.test.ts fixtures wrote background/border
colours as bare hex strings, but ColorSchema is an {r,g,b} object in 0..1 --
the fixtures type-checked as ContentBlock only because tsc's structural
narrowing under `as const satisfies` was never actually run against them
until now. Construct each one through rgbHexToColor instead.
…ent notation

Number-to-string switches into exponent notation below 1e-6 and at or above 1e21,
so formatOdfLength emitted values like "-7.1e-15pt".
The OASIS `length` datatype has no exponent form at all,
which made that output invalid ODF this package's own reader then discarded silently:
parseOdfTransform drops a translate() whose components fail LENGTH_PATTERN,
moving a rotated shape to its own pivot,
and parseBox returns undefined for an unrotated frame's svg:x/svg:y,
so readDrawFrame drops the whole shape and it vanishes from the slide.

That magnitude is ordinary rather than contrived.
A rotated frame's translate() components are trig-derived,
so a frame at or near the page origin cancels to rounding dust
instead of a clean zero at most angles.
A 100x100pt frame at (0,0) rotated 270 degrees
wrote translate(7.105427357601002e-15pt 100pt),
and reading it back placed the shape 100pt from where it was written.

Fixed on the write side rather than by widening LENGTH_PATTERN:
accepting an exponent on read would leave every other ODF consumer
seeing a length outside the datatype.
The expansion re-positions the decimal point
in the digits the shortest-round-tripping representation already chose,
so it is an exact re-spelling rather than a rounding step,
and carries no trailing fractional zeros for the same reason.
odf.js parses with processEntities:false,
so an attribute value in this package's model is the literal source text:
a shape named `Q&A <draft>` is stored as `Q&amp;A &lt;draft&gt;`.
readDrawFrame and the custom-shape text reader both projected that straight into
ContentShape.name without decoding,
so a name carrying any of the five predefined entities reached every consumer
still escaped, with no way to know it was.

Every other plain-text projection in this reader family already decodes --
svg:title and svg:desc via decodeOdfText,
a form control's label via forms.ts,
meta.xml's own fields via metadata.ts.
An attribute value takes decodeXmlText directly rather than decodeOdfText,
since text:s/text:tab/text:line-break are element-level spellings
that cannot occur inside an attribute at all.

A pre-existing reader defect, exposed for the first time by the odp writer:
nothing wrote draw:name before it.
paintOrder is the one ContentShape field the odp/odg reader always populates
(paintOrderKey stamps every draw:frame it walks),
so the odp writer's canonical form carrying frame/insets/name/rotationDeg/blocks
and nothing else made every odp -> odp and odg -> odp conversion
silently lose explicit z-ordering.
draw:z-index is the one spelling ODF has for a stacking order
independent of document position,
it is what the reader already resolves,
and typed/ods/write.ts's anchored-drawing frames already emit one,
so writing it here is existing precedent rather than a new convention.

A paintOrder ODF cannot spell -- negative, or fractional,
which ContentShapeSchema permits deliberately
so a value can later be inserted between two existing ones --
writes no attribute rather than being rounded onto a neighbouring shape's order,
and the reader's own document-encounter fallback then says the same thing.
canonicalShape reads that decision off odfZIndexOf rather than re-deriving it,
so the writer and the canonical form cannot disagree.

canonicalShape now also names the fields it drops instead of leaving them silent,
matching normaliseOdtContent's own convention.
fontScale and lineSpacingReduction are DrawingML a:normAutofit percentages --
the shrink factor PowerPoint computed and stored --
and ODF's own autofit vocabulary is a mode flag carrying no computed factor,
so writing one would invent a fact the input never stated
while still losing the one it did.
sourcePath and source are dropped for the reasons the odt writer already gives
for the identical fields.

Confirmed against LibreOffice 26.2.5.2: a slide whose shapes were written
in an array order deliberately unlike their own paintOrder (3, 1, 0, 2)
comes back through --convert-to fodp reordered into paintOrder order,
with the attribute dropped and the elements physically moved instead.
…blishes

The odp verification section said the conversion carried every piece of content
"byte-for-byte", which is not what was checked and could not be:
--convert-to fodp re-serialises the whole document through LibreOffice's own writer,
renaming styles, reordering attributes, and adding defaults of its own,
so its bytes differ from writeOdp's by construction.
What the check establishes is that every authored string
appears verbatim in that re-serialised output, which the section now says instead.

Records what the re-run added: rotated frames at the page origin,
shapes whose paint order disagrees with their document order,
a draw:name carrying XML special characters,
LibreOffice honouring draw:z-index by physically reordering the elements,
and the one class of defect a green soffice run cannot catch --
LibreOffice's own length parser accepts values outside the OASIS length datatype,
so exponent-notation lengths converted cleanly while being invalid ODF.

Also documents paintOrder writing as draw:z-index
and fontScale/lineSpacingReduction being dropped rather than approximated.
… surface

createDrawShapeWriteState, DrawShapeWriteState, and planShapeContent
were exported from the package entry point for a consumer that does not exist:
the state constructor takes a StyleRegistry plus the raw XmlElement container
automatic styles get appended to, which is internal plumbing
odp's own writer and a future odg writer hold between themselves,
not a shape any external caller has a use for.
ShapeContentPlan goes with them, since only planShapeContent produces one.

They stay ordinary exports of their own module,
which is all an in-package caller needs.
Publishing them would freeze that plumbing into the package's public API
ahead of any concrete requirement for it,
and turn every later change to it into a breaking one.

Nothing outside this package referenced any of the four,
and none of them has ever been in a released version --
they were added alongside the odp writer on this same unreleased branch --
so this removes an API surface no consumer can be holding.
odfZIndexOf accepted any Number.isInteger paintOrder, but
Number.isInteger(1e21) is true and String(1e21) is "1e+21" -- the exact
exponent-notation defect this branch's own formatOdfLength fix already
closed for lengths, reintroduced here for draw:z-index. A value beyond
2^53 now writes no attribute at all, matching how a negative or
fractional paintOrder already degrades to the reader's own
document-encounter order rather than a spec-invalid attribute.
…ation

readCustomShapeAsTextShape's own draw:name read was fixed to go through
readDrawName rather than a bare attrValue, but nothing pinned it -- the
whole suite passed with that call site mutated back to the unescaped
form. draw:frame's own sibling call site was already covered.
…them

The header claimed "THE THREE FIELDS THIS FUNCTION DROPS" while the
bullets underneath named four (fontScale, lineSpacingReduction,
sourcePath, source), and canonicalShape drops a fifth -- frames -- that
went unmentioned entirely, in a comment whose whole point is that
nothing is left silent. normaliseOdtContent already names all three of
sourcePath/source/frames together for the identical reason; this states
the same precedent rather than two of the three.
@Mearman
Mearman force-pushed the feat/odf-odp-writer branch from fe917c0 to 92e4471 Compare September 4, 2026 00:49
@Mearman
Mearman merged commit 29670de into main Sep 4, 2026
17 checks passed
@Mearman
Mearman deleted the feat/odf-odp-writer branch September 4, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant