Commit Graph
8 Commits
Author SHA1 Message Date
58ae1d5685 Use the browser clock for now_iso on wasm (comment and tracked-change dates) (#214)
Fixes #188.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 09:27:02 -04:00
663904a2e2 Import Word 97-2003 .doc files (#28) (#145)
* D0+D1: open Word 97-2003 .doc — container, FIB, piece table text (#28)

New wordcraft-docbin crate (L2) reads the OLE2 container via the cfb
crate (MIT) and parses the FIB and piece table from the [MS-DOC]
specification: text pieces decode as UTF-16LE or Windows-1252 (table
moved to wordcraft-doc::encoding), subdocument CP ranges, and Word's
special characters map to the model's (tabs, breaks, hyphens). .doc/.dot
registered in OPEN_EXTS and the I/O dispatch. Encrypted and Word 6/95
files give clear errors; every length is checked and proptest
truncation/flips never panic.

* D2: .doc formatting — stylesheet, fonts, CHPX/PAPX FKPs, sprms (#28)

Sprm decoder with operand-size rules (incl. sprmTDefTable/sprmPChgTabs
exceptions and the Prm0 isprm table), bin tables + ChpxFkp/PapxFkp page
lookup, STSH styles (StdfBase layout validated against real Word files:
bchUpe == cbStd, sti 0 = Normal) and the SttbfFfn font table in both
extended and self-delimiting flavours. The CP walk now splits runs on
formatting changes and resolves paragraph styles: bold/italic/strike/
underline/caps/hidden, colours (Ico palette + COLORREF), half-point
sizes, super/subscript, fonts, highlights; alignment, indents, spacing,
keep options, outline levels. Unknown sprms are skipped leniently;
an env-gated corpus test opens real files (styles + text asserted).

* D3: .doc structure — sections, headers/footers, tables, lists (#28)

- Sections: PlcfSed/Sepx with 16-bit measurement sprms (sprmSXaPage & co),
  explicit sprmSBOrientation with a width/height fallback; even/odd/first
  header and footer stories from Plcfhdd become document parts.
- Tables: sprmTDefTable grids (operand length fixed to cb+1 per the spec),
  horizontal merges (sprmTMerge), vertical merges (sprmTVertMerge), cell
  shading (sprmTSetShd / sprmTDefTableShd80), cell widths, valign, row
  height/header/cant-split, and table borders (sprmTTableBorders80 Brc80s).
- Lists: PlfLst (LSTF + appended LVLs) and PlfLfo (LFO + LFOLVL overrides)
  resolved into the numbering model; sprmPIlvl/sprmPIlfo mark paragraphs.
- Combined fixture (intro + table + list + 2 sections + header) round-trips
  through Document JSON; hostile proptests now mutate the structured fixture.

* D4: .doc notes, fields, bookmarks and inline pictures (#28)

- Footnotes/endnotes: PlcffndRef/PlcfEndRef reference CPs paired with the
  PlcffndTxt/PlcfEndTxt story ranges; each story walks into a Footnote or
  Endnote part and the 0x02 anchor becomes a NoteRef object.
- Fields: 0x13/0x14/0x15 state machine in the walker — instruction text is
  hidden, the cached result kept; HYPERLINK fields keep their result as
  linked runs, everything else becomes one Field object.
- Bookmarks: SttbfBkmk names + Plcfbkf/Plcfbkl CPs become BookmarkStart/
  BookmarkEnd objects at their anchors.
- Pictures: sprmCPicLocation points into the Data stream; the PICF header
  gives the display size (dxaGoal/dyaGoal scaled by mx/my) and the payload
  is format-sniffed (PNG/JPEG/GIF/BMP; metafiles are dropped with a warn).
- Hostile proptests now truncate/flip a fixture with all of the above.

* D5: .doc import hardening, caps and roadmap (#28)

- Cap inline pictures at 5 000 per document.
- Engine test: .doc/.dot bytes dispatch through the Word 97-2003 reader
  (garbage must error, never panic or open empty).
- ROADMAP: Word 97-2003 import row (60% — text, formatting, styles,
  sections, headers/footers, tables, lists, notes, fields, bookmarks,
  raster pictures).

Real-corpus validation stays opt-in: WORDCRAFT_DOCBIN_FIXTURES=<dir>
cargo test -p wordcraft-docbin corpus_files_open.

* Corpus fixes: compressed-piece FC space, PAPX query point, none-level labels (#28)

Validated against two real files (kept locally under plan/word/fixtures,
gitignored): a 1 MB Lorem-ipsum sample and a 2003 styles/lists/table
sample.

- FKP bin tables and pages address the WordDocument stream in true byte
  offsets; a compressed piece stores its base fc doubled (byte = fc/2),
  so fc_of_cp now unwinds the doubling. Before, every PAPX/CHPX lookup in
  compressed (cp1252) files landed on the wrong entry — tables collapsed
  to one row, lists and heading styles were lost.
- The paragraph-properties query now uses the paragraph mark's own FC
  (inside the paragraph) instead of the FC after it, which picked up the
  next paragraph's FKP entry.
- Fixture papx entries re-keyed to paragraph-start CPs, the real rgfc
  convention.
- Counters::next_label returns nothing for NumFormat::None levels (Word's
  numFmt none shows no label; also fixes a latent docx-reader case).
- Symbol/Wingdings bullet chars map to Unicode glyphs (• ▪ ○ ●).

With this, both corpus files render headings, styles, nested and numbered
lists, the tables and the PAGE field correctly; metafile-wrapped images
and OLE objects stay dropped by policy (logged).

* docbin: bound the text walk by real text; dedupe and cap pictures (#28)

Hostile CP numbers no longer drive the work:
- header/footnote/endnote story ranges from the Table stream are clamped to their
  subdocument (ccpHdd/ccpFtn/ccpEdn) with checked base additions, and every walk stops at
  the last piece's CP; a story spanning [0, 4e9) used to push billions of U+FFFD;
- characters are found by binary search over the pieces (PieceTable::char_at), and an
  unreadable stretch (gap, truncated or out-of-stream piece) is skipped in one step rather
  than one U+FFFD per CP; the old per-character scan was O(chars x pieces);
- the piece CPs are made non-decreasing at parse, so inverted pieces are empty and binary
  search stays sound; surrogate pairs now decode;
- note references are looked up by binary search; the style's CharProps is borrowed, not
  cloned, per character;
- pictures are interned by fc_pic (5000 anchors on one picture share one media entry and
  one copy) and the bytes copied per document are capped at 256 MB.

Each fix has a crafted-bytes regression test; against the previous code the story,
piece-claim, last-piece and many-pieces tests hang (killed at 60 s) and the shared
picture test copies the picture once per anchor (80 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docbin: checked offset arithmetic in lists, sections and table rows (#28)

- list.rs: PlfLst/PlfLfo/LVL offsets (at + 2 + cLst*28, at + 4 + lfoMac*16, off += len,
  ...) saturate, which matters on 32-bit targets (wasm32) where fc + n can overflow usize;
  the LFO override path no longer indexes abstracts directly.
- sections.rs: the Sepx range (fc_sepx + 2 + cb) is checked; Plcfhdd is read only as far
  as the stories a capped section count can use (it used to reserve lcb/4 entries).
- table.rs: sprmTDyaRowHeight = i16::MIN negated in i16 (overflow panic in debug builds);
  now -(v as f32).

Regression tests: the row height test failed before (overflow panic with debug assertions, a 0 pt height without); the list test pins the saturating
offsets (it only overflowed on 32-bit targets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format the merged OPEN_EXTS list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:36:38 -04:00
AiBizzAppsandClaude Opus 5.5 a45c8a41de DOCX: keep a .docm's macros and signatures on save; right content types for .docm/.dotx/.dotm (#172)
* DOCX: keep a .docm's macros on save; write .docm/.dotx/.dotm content types

Saving a .docm deleted its VBA project and wrote the plain-docx main
content type under the .docm name, which Word rejects; .dotx was written
as a document, and .dotm could be opened but not saved. io_ext sent every
Word extension to wordcraft_docx::write, which always emitted a .docx
package, and the reader never kept the VBA parts.

- wordcraft_docx::Flavor (docx/docm/dotx/dotm) picks the main part's
  content type; write_as(doc, flavor) writes it, write() stays .docx.
- The reader keeps word/vbaProject.bin (and the vbaData.xml it relates
  to) as opaque bytes in Document::passthrough, the mechanism custom.xml
  already uses. Macro content is never parsed or run. The writer emits
  them, with their relationships and content types, only for .docm/.dotm;
  a .docx/.dotx leaves them out cleanly (as Word does) and the open
  document still holds them for a later Save As .docm.
- Empty projects, missing or malformed VBA data, duplicate or odd
  relationships never panic and never stop the document from opening;
  part sizes stay under the existing package limits.
- .dotm joins the open/save lists, and saving to .docm/.dotx/.dotm binds
  the session to that file like .docx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: carry vbaData.xml as opaque bytes, never parse it

The first cut gated word/vbaData.xml through xml::parse on read and on
write. That parser rejects UTF-16 (a valid XML encoding), so a
UTF-16 vbaData.xml was silently dropped and the saved .docm kept
vbaProject.bin without its VBA data; and it is lenient (no end-name
check, auto-closes at EOF), so malformed data passed the gate anyway.
WordCraft is a passthrough for macro parts: it should emit exactly what
the user's file had. Now vbaProject.bin and vbaData.xml are both carried
byte for byte (bounded by the reader's existing size limits); only the
relationship part is read, to find the VBA data. A relationship to a
missing VBA data part keeps the project and writes no dangling link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: read UTF-16 XML parts instead of dropping them

xml::parse rejected any part with a UTF-16 BOM, and Package::rels
swallowed that error as "no relationships". A UTF-16
word/_rels/vbaProject.bin.rels (valid per OPC) therefore hid
vbaData.xml, and saving the .docm dropped it and its relationship.
parse now transcodes UTF-16 LE/BE with a BOM to UTF-8 (lossy, bounded
at 1.5x, never panics on odd lengths or unpaired surrogates), so every
part benefits. Unreadable relationship parts are now logged rather than
silently treated as empty; the macro project is still kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web Save keeps the opened Word format (.docm stays .docm)

Browser Save (file.save without a path) always downloaded
<stem>.docx, so an opened .docm/.dotm was written as a plain .docx and
lost its macros on an ordinary Save. Save and the Save As suggestion
now default to the opened file's extension when it is a Word package
(.docx/.docm/.dotx/.dotm, via engine io::is_word_package), else .docx.
Macros are only dropped on an explicit conversion to a macro-free name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: carry every part a VBA project relates to, so signatures survive

A signed .docm/.dotm lost its VBA signature on save: the reader kept
only vbaProject.bin and vbaData.xml, and the writer rebuilt
vbaProject.bin.rels with the wordVbaData relationship alone. Under a
"signed macros only" policy the macros then stop running, although the
project bytes (which the signature covers) were unchanged.

Generalised instead of special-casing signature types: the reader now
keeps every internal part vbaProject.bin.rels points to (VBA data, the
legacy/Agile/V3 signatures, anything else) as opaque bytes, with its
relationship type and its content type from [Content_Types].xml, in a
small manifest in Document::passthrough. .docm/.dotm re-emit them all;
.docx/.dotx still drop the whole VBA set. Bounded at 64 parts; targets
outside word/, relationship parts, the project itself, missing parts
and parts the writer produces itself are skipped, never shadowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: match VBA part names case-insensitively; keep colon names relative

Two edge cases in carrying a VBA project's related parts:
- The reader's word/ prefix check was case-sensitive, so a relationship
  to /WORD/vbaData.xml (the same part under OPC's case-insensitive part
  names, and found by Package::get) was skipped and lost on save. The
  directory is now matched case-insensitively and normalised to word/,
  the name the writer uses.
- Stripping word/ from /word/sig:legacy.bin gave Target="sig:legacy.bin",
  which parses as a URI scheme rather than a package part. A target
  whose first segment has a colon is now written as "./sig:legacy.bin";
  ordinary names keep the plain form Word already accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:34:52 -04:00
c0ccda288c Find/Replace skips text deleted under Track Changes (#89); a failed command keeps undo history (#153)
* Find/Replace: skip text deleted under Track Changes

With Track Changes on, Replace marks the match deleted and leaves it in
the paragraph text, then inserts the replacement in front of it. The
search helper matched the raw paragraph text, so the next step found that
same deleted "cat" right after the caret and replaced it again: four
Replaces on "one cat two cat" gave "one dogdogdogdog two cat" and
`remaining` never dropped.

`search` (used by Find, Find Next/Previous, Replace, its `remaining`
count and Replace All) now skips any match that overlaps a run marked
deleted, so deleted text is never found, as in Word. A match that would
run across deleted text is skipped too: that string is not in the
document as it now reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: a failed command keeps the redo stack and the oldest undo step

`run` checkpoints a mutating command before running it, and the
checkpoint clears the redo stack (and, once the history is at its limit,
drops the oldest step). When the command then failed, the error path put
back the document, selection and typing group and truncated the history,
but the cleared redo stack stayed cleared. Insert, Undo, then any
command that fails on its parameters (`text.insert {}`,
`para.align {value:"bogus"}`) made the undone work unrecoverable. At the
limit, truncating to the old length also kept the failed command's empty
step and lost the oldest real one.

The checkpoint now hands back what it removed, and the error path
restores both. join_next is deliberately left consumed: the drag that
set it re-arms it every frame, and restoring it would merge the next
unrelated command into the drag's undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Find/Replace: find live text next to a tracked deletion

The previous fix rejected any regex match that overlapped tracked-deleted
text, but `find_iter` had already consumed those positions, so a valid
match overlapping the rejected one was never tried. "aaa" with the first
"a" deleted reads "aa", yet Find "aa" found nothing (raw 0..2 was
rejected and the scan resumed at 2) and Replace All replaced nothing.
Searching the raw text also let deleted characters decide word
boundaries: deleting the space in "cat fish" still matched whole-word
"fish".

`search` now builds each paragraph as it reads, without deleted runs,
and searches each stretch of live text separately with
`regex_automata::Input::range`. A match stays within one stretch, as
before, and `\b`, `^` and `$` see the neighbouring live text, so a
deletion neither creates nor hides a word boundary. `regex::Regex` cannot
bound a search's end without slicing the haystack, which turns the slice
end into a word boundary; regex-automata is the engine `regex` already
uses (0.4.18, already in the lockfile), so no new code is built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: a failed command restores history changed by nested commands

The previous fix had the checkpoint report what it evicted and put that
back on failure, but it only saw the outer command's own checkpoint.
Commands run nested commands, and a nested one that succeeds checkpoints
too. `file.inspect {"remove":["comments","revisions"]}` in a
comments-only document runs `review.deleteComment` (allowed) and then
`review.acceptAll` (refused). With 499 undo steps, the nested checkpoint
evicted the oldest step and the rollback, truncating to 499, kept the
outer command's empty step instead. At 500, only one of the two
evictions came back. Nested undo, redo and version restore change the
stacks in ways a length cannot describe either.

Undo steps are now `Arc<Undo>`, and `run` snapshots both stacks whole
before a mutating command and puts them back on failure, so the
history is exact whatever ran inside. The snapshot copies one pointer
per step, under 1,000 refcount bumps and an 8 KB allocation at
MAX_UNDO, beside the two document clones every mutating command
already makes. Undo and Redo are not mutating commands, so a top-level
Undo still moves its document out of the step without cloning it. Only
an undo nested inside a mutating command (a macro) clones, because the
snapshot still holds the step. This replaces the eviction-return
plumbing, so `checkpoint` is back to its original form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: compare undo timelines without comment timestamps

failed_command_restores_history_changed_by_nested_commands builds the
same session twice and compares their documents. Comments are stamped
with the time to the second, so when the clock crossed a second between
the two builds the documents differed and the test failed (about 1 run
in 12). Clear the stamp in the compared copies; the test still checks
every other part of each document in the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Undo: wrap the typing-parity undo step in Arc after merging main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 06:34:35 -04:00
Brandon ThomasandClaude Opus 5.5 72262a2ea9 M3/M6/M12: PDF export (krilla), TXT/MD/HTML/RTF/ODT import+export, citations & bibliography, index, table of figures, cross-references, tools (repeat, macros, AutoCorrect, compare, accessibility, inspector, protection, versions, Quick Parts)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 c29182e8bc M1: DOCX read/write (OOXML) wired into open/save; floating objects; picture & shape tools; mail merge, envelopes, labels; contextual spacing; export without proofing marks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:46:40 -04:00
Brandon ThomasandClaude Opus 5.5 d1a0ed39c0 M7.1: proofing — spelling with suggestions, grammar checks, squiggles, F7, right-click menu, thesaurus; web app; hyphenation dictionary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:38:37 -04:00
Brandon ThomasandClaude Opus 5.5 b8dd46ccb5 M0.7: engine — session, undo, 200+ commands, Word feature catalog, samples
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:47:26 -04:00