Compare commits

...
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 5a4c45e79a About: let every credits column be dragged and refit changed tables (#5588)
* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:16 +01:00
Dc-xp 6f552abf8e chore: follow craft-libs c5a1cbf for craft-pdf and krilla (#5281)
Same rev the other editors are on; cargo check -p wordcraft-ui-egui is clean.
2026-11-05 02:10:16 +01:00
Dennis Klappe 5f87d7461b packaging: RPM installs on openSUSE (library sonames, no shared directories) (#5369) 2026-11-05 02:10:10 +01:00
Rahulandrahul05ranjan 6a50c1e374 #5289: a section's page start flushes the page before with its own margins and separators (#5291)
The page the section before ends on is finished with its own margins and
footnote separators before the starting section's replace them, so the
footnotes pending on it are placed where the page they sit on says.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-05 02:10:03 +01:00
entropybeaterandBrian 4123bdc09f docx: keep a paragraph's text alignment (#5498)
* docx: keep a paragraph's text alignment

w:textAlignment (ECMA-376 17.3.1.39) was not read, so a save dropped it. Word sizes a line by it when a
list's bullet is larger than the text: without it, those lines grow and every page after them moves.
ParaProps gains text_alignment, read and carried through styles, and written back after w:jc. Kept for
the file only; the layout does not use it yet.

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 02:09:55 +01:00
Harrison BoothandClaude Opus 5.5 b3da7a4d36 Object drag preview: clamp the texture to the GPU limit (#4578)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:08:58 +01:00
Valentin KleinandClaude Sonnet 5.5 5e4c92bf20 contributors: set names for vangel76 (#5520)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:01:19 +01:00
.CooCoo 61a1808f0f Docs: spelling skips addresses after narrow no-break and figure spaces (#5528)
Spelling as you type now notes that a web or e-mail address after a
no-break, narrow no-break (U+202F), figure (U+2007) or ideographic space
is not marked while Ignore web and file addresses is on, as
address_after_a_wide_space_is_an_internet_token in craft-proof checks.
2026-11-05 02:01:13 +01:00
Joseph AngladaandClaude Sonnet 5.5 208facfb92 SmartArt: custom colours per item from the Text Pane (#3123)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:47:56 +01:00
entropybeaterandBrian 811a847e86 doc, docx: keep the compound border lines (#5499)
* doc, docx: keep the compound border lines

The nine thin and thick compound lines (thinThickSmallGap to thinThickThinLargeGap, ECMA-376 17.18.2)
were read as a single line and written back as one, on paragraphs, tables, cells and pages. BorderStyle
gains them; they are written back by name and drawn as a single line of the border's width, as before.
RTF export and import carry them too (the importer read four of them as double).

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 01:40:16 +01:00
Valentin KleinandClaude Sonnet 5.5 c9ecb1a14b Metafiles: read the picture size from the header instead of parsing the whole file (#5481) (#5493)
metafile_size() parsed the whole metafile, decoding every bitmap and building every path, to
read two numbers at picture insert and replace. A new wordcraft_metafile::size reads the frame
from the EMF header or the placeable WMF header, and falls back to the full parse for every
other file (plain WMF, an EMF without a usable frame, a file with no records), so it returns
what parse would. For an EMF with one 8000x5000 1 bpp bitmap the size now costs well under a
microsecond instead of about 280 ms.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:30 +01:00
Valentin KleinandClaude Sonnet 5.5 c38cd8c407 Metafiles: decode DIB bitmaps with the bit depth chosen once per bitmap (#5479) (#5492)
decode_clip matched the bit depth, bounds-checked and (for 16 and 32 bpp) divided per channel
for every pixel. The depth, palette and masks are now turned into a row decoder once per
bitmap: a 256-entry colour table for 1/4/8 bpp, plain channel swaps for 24 bpp and default
32 bpp, and precomputed shifts and scale tables for masked 16/32 bpp. Output is byte for byte
the same; the old per-pixel code stays in the tests as the reference.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:25 +01:00
Valentin KleinandClaude Sonnet 5.5 991a21dee6 Metafiles: refuse a bitmap whose side no renderer draws before decoding it (#5480) (#5490)
decode_clip only capped width x height at 40 MP. A 32 x 1,250,000 pixel 1 bpp bitmap (about
5 MB in the file) was decoded to 160 MB of RGBA and kept, then dropped by Item::take_pixels
because its side is over MAX_BITMAP_SIDE, while counting against the parse's 256 MB budget.
decode_clip now returns None when the clipped width or height is over the cap, so crops of a
large bitmap that fit still decode.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:20 +01:00
Hamza Naveed cbe6a9d5f7 Pin that a comment id past u32 is refused, not truncated (#3677) (#5273)
* Pin that a comment id past u32 is refused, not truncated (#3677)

`validate_comment_id` refuses an `id` that does not fit a `u32`, so the `as
u32` casts behind it can no longer reach another comment -- but nothing pinned
it, and the casts are still there to be reached if the guard moves.

The ids the test sends are the ones that would alias onto real comments if the
truncation came back: 2^32 truncates to 0 (and the first comment is 0), 2^32 +
id to that comment, 4294967301 to 5 as the report describes, and u64::MAX to
u32::MAX. Each of the four commands that take an id is tried with each, then
the comments are checked to be unchanged and unresolved, and a valid id is
accepted afterwards so the guard is shown to be a range check rather than a
refusal.

* Comment ids: the values past u32 go in the existing invalid-id test, refused by the range check
2026-11-05 01:32:15 +01:00
.CooCoo c419985c54 Tests: the balloon anchor test counts work steps instead of timing (#5608)
anchors_walk_dense_unrelated_markers_in_linear_work compared CPU times of
a few microseconds, so cache and timer noise could push the ratio past its
24x limit on a linear walk (43.52x in a CI run).

wordcraft-doc now counts, in tests only, the characters its paragraph
object lookups walk (objects_before, object_offsets and
objects_with_offsets), through a work::count helper behind the existing
test-support feature. The test asserts that 8x the markers take at most
12x the steps, as the layout linear-time tests do: the walk takes 2001 ->
16001 steps (8.0x), and the object_offsets + object_at walk it replaced
takes 2003001 -> 128024001 (63.9x).
2026-11-05 01:32:10 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 1c7b5013fa Drop Cap follow-ups: whole selection, script fonts, margin gap, menu keeps options (#4077)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:26:57 +01:00
Dylan M. Taylor 3f555bc85d Pass the Linux signing key to the release workflow (#5614) 2026-11-04 17:44:46 -05:00
Aleksander HelgakerandClaude Opus 5.5 650a97b623 ODT: remove the display settings; one choice for each open case (#5609)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:08:09 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 78d71fa19c Insert Date & Time and date fields use local time, not UTC (#1782)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 22:43:19 +01:00
RatkoRandClaude Opus 5.5 4932017711 Dialogs are modal, as in Word (#4076)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:31:22 +01:00
NetterSilverandClaude Haiku 5.5 0802b4fe70 Layout: the caret on the empty line after a trailing line break follows the paragraph's alignment (#4293)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:57:34 +01:00
mmazzieriandClaude Opus 5.5 75fc50fb77 DOCX: picture brightness, contrast and transparency (VML washout, a:lum, a:alphaModFix) (#1961)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:47:44 +01:00
rabis7575andClaude Opus 5.5 1f026078be Format Shape: the Angle field leaves an angle above 359.9° alone (#3959)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:33:52 +01:00
Joseph AngladaandClaude Opus 5.5 bf7715785c Desktop: a window restored onto no monitor opens on screen (#4742)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:19:03 +01:00
FredApps 573b929ad6 Merge pull request #5593 from storytold/fix-5559-followups
Fix #5559 follow-ups: generated list prefix, hidden-text objects, turned clipped lines, kept-markup checks
2026-11-04 18:47:52 +01:00
FredApps 5c427807d8 Give a wrapped list its own paragraph when another list field precedes it 2026-11-04 18:17:20 +01:00
FredApps 006d9e4b5b Rebuild a wrapped list as its own field when another list field precedes it
update_generated now keeps what comes before a wrapped list's field, which can be
another list field; rebuild from the converted field's code instead of the
paragraph's first list field, and let index::rebuild_from take that code.
2026-11-04 18:00:11 +01:00
FredApps 8218dea5bd Fix #5559 follow-ups: generated list prefix, hidden-text objects, turned clipped lines, kept-markup checks once per export
- update_generated keeps what precedes a wrapped list's field (r4468529243)
- from_doc keeps objects in hidden text when hidden text is written (r4469018820)
- clipped cell lines in a turned frame are drawn once, clipped, turned (r4469018900)
- kept::while_writing works out reply map and page fingerprint once per export (r4468529227)
- odt_charts fixture declares its namespaces (r4468529259)
- guard test for character styles based on a missing parent (r4469018843, not a bug)
2026-11-04 17:43:46 +01:00
FredApps 92ecb40105 Merge pull request #5559 from alehel/ccr-84d15855-h5i4o2
ODT: full open, edit and save support, lossless on save without edits (gap 19)
2026-11-04 16:28:18 +01:00
jpc7k1andClaude Opus 5.5 63ceaf5b80 File › Print: Return prints, as Word's default Print button does (#4651)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:16:16 +01:00
FredApps 69945e0ccb Merge branch 'main' of https://github.com/storytold/wordcraft into pr-5559
# Conflicts:
#	ROADMAP.md
#	docs/layout-parity.md
2026-11-04 16:08:39 +01:00
pakiatandClaude Opus 5.5 2df2bfc30e Show fresh SEQ, STYLEREF and REF results in PDF export and printing (#5162)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:01:58 +01:00
FredApps 7c6341cf50 ODT: write one style:text-position for raised superscript/subscript text 2026-11-04 15:54:52 +01:00
FredApps 6d22e36196 Merge branch 'main' of https://github.com/storytold/wordcraft into pr-5559
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/layout-parity.md
2026-11-04 15:54:52 +01:00
ReesandClaude Opus 5.5 958867b7e8 Walk Print Markup balloon anchors once (#3614) (#4785)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:51:39 +01:00
Mohamed ElsayedandClaude Opus 5.5 e3b000b650 fix(print): report noPrinters when CUPS discovery finds no destinations (#1826)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:41:20 +01:00
LQRDandClaude Opus 5.5 604ac6ddb2 #641: print N-up sheets and folded booklets (#3916)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:29:12 +01:00
FredApps 3c7aa5df90 Merge branch 'main' of https://github.com/storytold/wordcraft into pr-5559
# Conflicts:
#	crates/formats/src/odt/import.rs
2026-11-04 15:24:27 +01:00
FredApps 87cfbac0ee Merge branch 'main' of https://github.com/storytold/wordcraft into pr-5559
# Conflicts:
#	ROADMAP.md
#	crates/formats/src/odt.rs
#	crates/formats/src/odt/body.rs
#	crates/formats/src/odt/import.rs
#	crates/formats/src/odt/page_parts.rs
#	docs/file-format-parity.md
#	docs/gaps.md
2026-11-04 15:13:22 +01:00
Bryan MarkandClaude Opus 5.5 b2734c28f4 Undo: steps share the style sheet and list definitions (1.2 GB → 21 MB at the limit) (#2694)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:03:13 +01:00
ReesandClaude Opus 5.5 ed231fd027 Walk Print Markup comment objects once (#3614) (#4744)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:49:31 +01:00
FredApps 06d699dcb7 ODT: round-5 review fixes 2026-11-04 14:44:19 +01:00
VoiceLessQ 560565cdd7 Audit fixes for 12 issues: import limits, escaping, link schemes, table depth (#3862) 2026-11-04 14:13:36 +01:00
Valentin KleinandClaude Sonnet 5.5 1350f62bf0 #2726: AccessKit focus guard — every tree update carries its focused node (#2742)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 13:59:15 +01:00
FredApps 8b9f726ea7 Merge main (688cce47) into ODT PR 2026-11-04 13:44:24 +01:00
.CooCoo 688cce4731 Read Mode: ink and drag previews follow the Page color, which is kept between entries (#4228) 2026-11-04 13:41:06 +01:00
FredApps b6afa0ba3c Review fixes: kept object names skipped (headers too), self-closed settings, STYLEREF/XE after a leading space, RTL padding, split cell backgrounds, PAGE in a table moved by a page break, written-as-read doc 2026-11-04 13:38:55 +01:00
6ec85141d5 Arabic interface: mirror the remaining label/field dialogs (#5449)
* Arabic interface: mirrored right-to-left window and Arabic UI fonts

Adds the Arabic (`ar`) interface language's code: its catalogs are craft-i18n's
`locales/ar`, so this changes no catalog.

- `ar` is registered in `i18n::LANGUAGES` and `Lang::rtl()` says it is written
  right to left; its translations are used as written (egui's text layout
  orders each line's bidi text itself). A ribbon location reads from the right
  with a leftwards mark.
- `rtl.rs`: the window is mirrored. Containers open in `rtl::root`; columns,
  grids, checkboxes, radio buttons, menus, rows, panels, the dialog tab strip
  and the ribbon's scrolling row follow the language's direction. Title bar,
  status bar, ribbon tabs and groups, File pages (including Print, with the
  settings on the right and a solid scrollbar gutter), dialogs, menus and the
  task panes (their docking edge swaps) are laid out from the right. egui's
  `Grid` panics in a right-aligned layout, so grids go through `rtl::grid`.
- Arabic UI font fallback: the craft-fonts Arabic face, else an installed
  Arabic font, loaded when the interface is Arabic.
- The "Auto (200%)" size label keeps its percent sign inside the isolated
  number.
- Tests: Arabic locale, saved preference and lookup order on fixture catalogs;
  an interface audit (skipped without the craft-i18n Arabic catalog) that lays
  the app out right to left at several widths, checks tab, group, pane, Print
  and Go To positions and that nothing panics.

The Arabic work builds on Abdelhaq El Amraoui's mirrored-interface PR (#737).

Co-Authored-By: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: pin craft-i18n at a commit with the Arabic catalog; review fixes

- craft-i18n.rev and the Flatpak manifest pin 19358491 (craft-i18n main), the
  first commit line that has locales/ar/wordcraft.tsv. The old pin had no
  Arabic catalog, so `ar` was registered but not embedded and two i18n tests
  (the_language_setting_is_a_ui_command_and_persists,
  the_languages_offered_are_the_embedded_ones_of_the_list) failed in CI.
- The Arabic font fallback is requested for any right-to-left interface
  (`Lang::rtl()`), not only after the language picker sets the flag, so an
  automatic or saved Arabic gets the installed Arabic face.
- The file browser, the update window and Reading Mode's toolbar run their
  bodies in `rtl::root`, so their far-side buttons and rows are mirrored.
- A group in a right-to-left ribbon row is measured from the right edge: the
  cursor's left edge is negative infinity there, so its width was NaN and the
  ribbon planner dropped every group, never compacting a narrow Arabic window.
  A narrow window now folds groups into drop-downs.
- The mirrored checkbox and radio marks are labelled by their text, so screen
  readers name them.
- Tests: group width in a right-to-left row; the Arabic interface audit checks
  Reading Mode's toolbar.

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

* Arabic interface: ask for the installed Arabic font separately from the CJK one

A previous commit made every right-to-left interface request the installed CJK
fallback font (`system_cjk`) so that the installed Arabic font would load. That
also made every Arabic launch read a large system CJK font file when no
craft-fonts input is embedded (the Flatpak build has none).

`install_ui_fonts` now passes a separate `system_arabic` flag (`Lang::rtl()`):
the installed Arabic font is read for it, and `system_cjk` is again set only
by a CJK interface or by CJK text on screen. `WordApp::fonts_system_arabic`
remembers what was installed, so a language change reinstalls the fonts.

Tests: the_installed_arabic_font_is_asked_for_by_arabic_alone (the Arabic flag
adds the installed font only when none is embedded, and the CJK request alone
adds no Arabic font). Checked a headless screenshot of the Arabic interface
without craft-fonts: the installed Arabic font draws the labels.

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

* Fix the AutoSave switch knob: on is at the right, mirrored in Arabic

The title bar's AutoSave knob was placed with `lead_x(rtl == on, ..)`, which
reverses main's mapping in both directions: left to right it drew the knob at
the left when the switch was on, and right to left at the right. main draws it
at the right when on and at the left when off, so the mirrored interface must
draw it at the left when on: `rtl != on`.

The position is now `autosave_knob_x(rtl, on, track)`, pinned by a test for all
four cases. A headless screenshot of the off switch shows the knob at the left
in English and at the right in Arabic.

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

* Arabic interface: file names and paths in right-to-left lines; kerning row order

Tester feedback on the Arabic interface:

- File > Open's Recent list, the built-in file browser and the title bar drew
  file names and paths raw, from the left edge. In an Arabic line the marks of
  a name ("(نهائي) 2.docx") and the folders of a path with Arabic parts were
  scrambled. Rows now start at the side reading starts from (`rtl::lead_x`),
  names go through `i18n::isolate` (a first-strong isolate, with a left-to-right
  mark before the extension so `2.docx` stays `2.docx`) and paths through
  `i18n::isolate_path` (a left-to-right isolate with a mark after every
  separator, so neighbouring Arabic folders do not swap). Other languages get
  the text untouched; saved and spoken text never carries the marks.
- Font dialog, Kerning row: the dialog's grid is drawn left to right inside a
  right-to-left window, so "Points and above" came after its number; in an
  Arabic interface it now comes first, so the line reads "14 نقطة فأكثر" from the
  right. (The grid's columns themselves still keep their left-to-right order.)

Tests: names_and_paths_are_isolated_in_a_right_to_left_interface.

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

* Arabic interface: mirror through craft_ui::direction and Style::right_to_left

The suite's craft-ui now has the direction helpers (`craft_ui::direction`) and
its egui fork a `Style::right_to_left` that mirrors egui's own chrome. Use them
instead of our parallel wrappers:

- `rtl::sync(ctx)` applies the interface direction every frame the language's
  changes (`direction::apply`): it sets the shared direction state and
  `Style::right_to_left` on every theme, which draws check boxes and radio
  buttons with the mark at the reading start, mirrors submenus, scroll bars,
  title bars and the combo boxes' arrows (the Print page's choice lists, the
  font list) and opens combo menus from the right.
- `rtl::checkbox`, `radio_value`, `radio` and `indeterminate_checkbox` are
  gone: about 200 call sites use plain `ui.checkbox`, `ui.radio_value`, ... and
  egui does the mirroring (and names the control by its text, as a plain widget).
- `lead_x`, `lead_center`, `lead_top`, `start_panel` and `end_panel` delegate to
  `craft_ui::direction`. `root`, `flows`, `island`, `grid`, `menu`, `vertical`,
  `far_side` and the other layout helpers stay: the shared crate has no
  equivalent that fits them.

Visual changes: the combo boxes' drop-down arrows and lists are mirrored in
Arabic; nothing changes in left-to-right languages (checked headless
screenshots of the English main window, Font dialog and Print page).

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

* Recent files and the file browser: full path on hover, long names keep their start

Review feedback:

- Each row of File > Open's Recent list shows the document's full path as its
  tooltip (the path line under the name can be longer than the row).
- A long name in the built-in file browser is laid out in one row, with an
  ellipsis at its end, from the side reading starts from, instead of being
  clipped at the row's edge: in a right-to-left interface the old text anchored
  at the right edge cut the end of an Arabic name and the start of a Latin one.
  The whole name is the row's tooltip when it was shortened.

Checked headless screenshots of the file browser with a long English and a long
Arabic name.

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

* Font combos in a right-to-left row: the arrow joins its field on the left

The ribbon's font name and size boxes are drawn from a field and a separate
arrow box that share one outline: the field is square and the arrow's box is
rounded on the side where the arrow goes, painted a border width under the
field. That assumed the arrow is right of the field. In a right-to-left row
(the Arabic ribbon) the arrow comes after the field on its left, so the field
was square on the wrong side and the arrow box rounded on the side facing the
field: two boxes with a gap-like seam, the arrow looking outside the frame.

`arrow_box_shape(rtl, ..)` mirrors the radii and the underlap, and the field's
square corners are chosen by the row's direction (the nw/sw pair in right to
left). Left-to-right rows are drawn exactly as before (English before/after
crops are identical).

Test: the_arrow_box_is_rounded_away_from_its_field.

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

* Pin craft-i18n 4612850 (merged Arabic WordCraft rows, craft-i18n#76)

The pin and the Flatpak manifest commit move from 1935849 to 4612850, which is on
craft-i18n main and adds the 70 Arabic rows this branch relies on.

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

* Pin craft-i18n cac2cf0 (Arabic number agreement and status-bar rows)

Moves the pin and the Flatpak manifest commit from 4612850 to the current craft-i18n
main, which includes craft-i18n#95 (48bed03) and #81 (ef58ff3).

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

* Arabic: isolate paths shown beside labels; mirror the Print page zoom

File > Info shows Location as its own left-to-right path label (cut short with the full path on hover, not wrapped), and the open-failure, Exported and dictionary-file messages isolate their paths. The Print preview zoom puts minus at the slider's left end and plus at its right in Arabic, as the status bar does.

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

* Arabic: mirror the label/field rows of the Font, Info, Paragraph, Page Setup, Insert Table and Options forms

rtl::Table lays a form out as an egui Grid in a left-to-right interface (same id, columns and spacing, so the output is unchanged) and, where the interface is mirrored, with each row's cells running from the right, every column as wide as its widest cell. File > Info puts Properties at the right and Statistics after it, and the Font dialog's kerning row reads 14 points and above.

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

* Docs: record the mirrored Arabic interface in the roadmap, gaps and localization parity

Progress log, the Arabic language row, gap 11 and the status lines and revision histories of the three docs.

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

* Arabic interface: Insert Hyperlink, Word Count, Phonetic Guide, New Style, Modify Style, Encrypt, merge rule and Match Fields dialogs read right to left

Their label-and-field rows use rtl::Table, so in a mirrored interface the label is at the right with its field to its left. rtl::Table gains striped() for Match Fields. English renders are pixel-identical (only a caret-blink strip differs in an untouched dialog).

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

* Arabic interface: Line Numbers, Hyphenation, Drop Cap, Caption, Cross-reference, Index, Mark Index Entry and Source dialogs read right to left

Their label-and-field rows use rtl::Table (label at the right, field to its left). English renders are pixel-identical.

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

* Arabic interface: Picture Size, Split Cells, Convert Text to Table, Footnote and Endnote, Table Properties, Cell Options and Custom Properties dialogs read right to left

Their label-and-field rows use rtl::Table. Picture Size's Height and Width pairs read Height first at the right. English renders are pixel-identical.

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

* Arabic interface: Define New Multilevel List, Track Changes Options, content control properties, custom colours and table style dialogs read right to left

Label, field and swatch rows use rtl::Table. English renders are pixel-identical.

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

* Arabic interface: Columns widths, Special Characters, Compare, AutoCorrect and About Models tables read right to left

Table columns now start at the right edge (rtl::Table gains top(), min_row_height() and an unset column count). About > Models stays a left-to-right scroll box with its columns listed from the right. English renders are pixel-identical (only the caret blink differs).

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

* Arabic interface: table-of-contents styles, recipient filter and sort, and the ribbon Paragraph group read right to left

rtl::Table now stacks its rows top to bottom whatever way the area around it runs (a ribbon group lays out in a row). English renders are pixel-identical (caret blink and a status toast differ in untouched dialogs). The TOC options and recipient filter/sort rows are converted by pattern and not seen in an image.

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

* Arabic interface: Columns preset tiles and Preview label, Macros, Add/Remove arrows, Zoom slider and the Recipient List grid

A preset tile is as wide as its caption and centres it (the long Arabic captions ran into the next tile); the Preview column keeps its width so its label no longer wraps letter by letter; Macros puts the name box and list at the reading start with the buttons beside them; the Add >> and << Remove arrows swap in a mirrored interface so they point the way the items move; the Zoom slider is drawn left to right as in English; the Recipient List data grid scrolls left to right instead of running past the left edge. English renders are pixel-identical.

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

* Arabic interface: the chart type and SmartArt layout galleries list their tiles from the right

The chart type dialog, the Insert SmartArt dialog and the ribbon's SmartArt Layouts gallery use rtl::Table rows, so the first tile sits at the reading start. English renders are pixel-identical.

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

* Take main's panes/host.rs after the merge

The merge kept a use of mirrored() that main's pane-dock fix removed; this branch does not touch the panes.

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

* Arabic interface: Columns preset captions are translated in the mirrored tile

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

* Arabic: one Arabic and one French row in the localization parity table; About > Models columns read from the right

The merge left the Arabic and French rows twice in docs/localization-parity.md; each
is now a single row (main's current French text, the Arabic row with the dialogs
note). About > Models keeps main's fitted table but lists its columns, their
alignments and grow weights reversed when the interface is mirrored, so Company
starts at the reading edge. The credits.rs change comes from tareq's helper merge.

Co-authored-by: tareq <tareq.halaby@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* About > Models: the mirrored table keeps its own widths and scroll position

The columns are listed in reverse when the interface is mirrored, and egui keeps the
resizable column widths and the scroll offset by id. A language switched at run time
would apply one direction's widths to the other's columns, so the mirrored table uses
its own id.

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

* Arabic interface: About > Models alignment mirrors as its comment says

The mirrored Models table reversed the per-column right-align flags but
did not negate them, so in Arabic the text columns stayed left-aligned and
the counts right-aligned, the opposite of the comment ("counts line up on
the right (left in RTL)"). Negate after reversing so the text sits at the
reading edge and the counts on the left. Keep this line when touching
models_ui.

* About > Models: a mirrored table that overflows opens at its reading edge

The reversed Arabic table puts Company in the rightmost column, but the
horizontal scroll area started at the left, so when the table was wider
than the dialog (narrow window, widened columns) Company and Model were
offscreen. Stick the scroll area to the right in a mirrored interface.

---------

Co-authored-by: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: email <email@email.com>
Co-authored-by: tareq <tareq.halaby@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-04 13:36:55 +01:00
midasdfandClaude Opus 5.5 2d6dff96cc Layout: 行頭禁則 lists small kana and the prolonged sound mark (JIS X 4051 §4), with the emergency-break gap documented (#2558)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:31:39 +01:00
FredApps d2f504e29a Review fixes: package-wide PBKDF2 budget, CLI keeps the ODF style, turned linked boxes, path close, centred PDF fill, doc status (gap 19) 2026-11-04 12:50:36 +01:00
.CooCoo e847b2e5e3 AutoFormat: a tracked heading or border line replaces another author's formatting change (#4231) 2026-11-04 12:27:08 +01:00
VoiceLessQ fd1c5efe6c Format readers: one border-type table for .doc, one HYPERLINK parser for DOCX, RTF and .doc (#4222) 2026-11-04 12:13:39 +01:00
VoiceLessQ 655b4312d3 DOCX: nil shading paints no fill, border theme colours win (part of #1991) (#4705) 2026-11-04 11:58:06 +01:00
FredApps 773ef21f9f Merge origin/main into the ODT branch (gap 19)
# Conflicts:
#	ROADMAP.md
#	crates/layout/src/tests.rs
#	docs/gaps.md
#	docs/layout-parity.md
2026-11-04 11:57:31 +01:00
FredApps 53bccd5442 ODT: two display styles (Microsoft Word style, LibreOffice style) and review fixes (gap 19)
- odf.preset: word (Microsoft Word style, default) or libreoffice; wordcraft still read as word
- Options, CLI (WORDCRAFT_ODF), docs and ROADMAP follow the two styles
- CodeRabbit: kept groups and story fingerprints follow fill_objects; padding serde default;
  list field depth; literal doubled quote; ASK/SET results not exported; dc:date offsets;
  first_pages order; unique picture names; finite raise clamp; PDF rotated-text guard;
  .ott in the password message; flat element bounded by depth; cell tile fallback;
  ROADMAP and docs reconciled
- Remove clean-room wording from the ODT tests
2026-11-04 11:55:49 +01:00
Aleksander HelgakerandClaude Opus 5.5 ba58e70cc8 Flag document fonts that aren't installed (#4770)
* Gap 4: flag document fonts that aren't installed

The font box (ribbon, mini toolbar, both Font dialog boxes) shows a
warning mark when the current font isn't installed; its hover note
names the font drawn instead. The status bar says "N fonts not
installed" and lists each with the faces drawn instead, plus Add Font….
Agents get `font.missing` and `format.state.fontShownAs`.

Layout records, where it picks a face per text segment, the fonts the
shown text asks for that aren't installed and the faces drawn instead
(ParaLayout::substituted, DocLayout::substituted_fonts), so hidden
text, unused styles and orphaned stories never count; list labels,
field results and East Asian and complex-script text do, and so does a
missing roman font drawn in Cambria. Metric-compatible substitutes and
symbol fonts drawn as Unicode aren't flagged. Bounded for hostile
files: 64 pairs a paragraph, 1024 a document, names kept to 256
characters, 20 rows in the popup.

Ported from alehel/wordcraft#1 onto current main; English strings only
(translations belong in storytold/craft-i18n).

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

* Gap 4: missing-font review fixes

- Judge a padded font name by the face layout drew for it, and skip blank names.
- Name the face CJK text is drawn in for a missing East Asian font, not Cambria.
- Keep the missing-fonts list per window, so several windows don't rebuild it every frame.
- Expose the font box's warning to screen readers as its description.
- Count missing fonts with craft-i18n plurals (`i18n::tn`).
- One table of metric twins (`fonts::word::METRIC_TWINS`): a missing font is drawn in an
  installed twin first, and the warning reads the same table.
- Find the status-bar item by part of its label in the click test.

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

* Gap 4: warn for a font picked at the caret; drop fonts of hidden list items

- At a collapsed caret the font box warns for a missing font typing will use, also at the end
  of text in another font (it waited for the first letter).
- A list item that shows nothing (#3982) reports no missing font: its number was shaped, and
  its font recorded, before layout dropped it.

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

* Gap 4: say where Add Font… is, and that it needs the font file

The font box's note said "Add Font… installs it": Add Font… opens a file picker at the end of
the list, so it only helps with a font file at hand.

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

* Gap 4: docs say the popup lists 20, and when the font box warns ahead

CodeRabbit review: the status-bar popup lists the first 20 missing fonts, then a count; the font
box also warns at a collapsed caret for a font no shown text uses yet; `fontShownAs` is the
font's substitute, fallback faces aside.

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

* Gap 4: the Font dialog warns for a missing font over a selection

CodeRabbit review: the dialog's boxes show the fonts OK applies, which no shown text may use
yet (an Asian text font over Latin text, a font typed into the dialog), so they name the
substitute whatever is selected. The ribbon's box over a selection still reads the list.

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

* Gap 4: the Asian font box names the face CJK text gets

CodeRabbit review: the status bar's list is by font name, so for a font Latin text also uses
it named the Latin text's substitute. The Asian text box now always looks up the face layout
draws Chinese, Japanese and Korean text in.

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

* Gap 4: gaps.md status on the current line, missing-font scope and twin wording

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

* Gap 4: gaps.md says which missing fonts are flagged and what font.missing lists

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

* Gap 4: font.missing lists what the current view lays out

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:48:48 +01:00
Bryan MarkandClaude Opus 5.5 6f62716f08 Text boxes: a press inside one goes to its text, not to a picture under it (#4240)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:31:58 +01:00
alehelandClaude Opus 5.5 56c85834f1 Merge origin/main into the ODT branch (gap 19)
Conflicts kept both sides. Where the two branches met:
- Para.mark: both added it. It stays CharProps (ODT needs it); HTML's
  `<p>&nbsp;</p>` converts its Fmt, and to_doc reads it in one place.
- Arrowheads: the PR's line ends (top left to bottom right) with main's
  arrowhead styles.
- inline_rect: the PR's depth below the baseline with main's rule room.
- Spacing and Cell literals take main's new fields.
- The zip entry guard (#3660) also covers the encrypted ODT paths, which
  parsed a package before it; regression test in tests_more/encryption.rs.
- chunks_exact to as_chunks in odt/frames.rs and odt/path.rs (clippy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:51:35 +01:00
Claude c26b952e83 M-ODT gap 19: paragraphs tied together written as read as groups
A comment, tracked change or index mark reaching from one paragraph
into another, or a text box continuing in another's, ties paragraphs
into a group: written as read while every paragraph of it is as read,
else all from the model, which names what ties them afresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 09:18:04 +00:00
Claude 57b03d9af8 M-ODT gap 19: paragraphs with charts written as read
Opening draws charts from their data, which changed their paragraphs'
fingerprints, so their markup never matched. Drawing goes through
Document::fill_objects, which moves the kept markup with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 04:51:01 +00:00
Claude ea420c467c M-ODT gap 19: sections in table cells and at empty paragraphs kept
A section in a table's cell is kept around the cell's paragraphs and
closed in the cell. One starting or ending at a paragraph with twins
(an empty one) is kept by that paragraph's own identity; the writer
opens each kept section once, so a copy can't open it again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 04:28:49 +00:00
Claude 5f0c08b68d M-ODT gap 19: password, number, time and grid form controls kept
Controls of kinds WordCraft doesn't model (ODF 1.3 §13.5) were dropped
with their place in the text; they are kept verbatim as buttons and
image controls are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 03:51:16 +00:00
Claude 610233de3d M-ODT gap 19: element audit; index marks, plugins kept as read
Every element of the ODF 1.3 schema checked against both corpora and a
document of our own for each one no file held (docs/odt-element-audit.md).
Found and fixed: index and reference marks over text, plugins and
floating frames weren't kept as read, and a form's data source was
taken for a control. Left: password and grid form controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 01:33:26 +00:00
Claude b52f3ea68d M-ODT gap 19: reference marks written as reference marks and bookmarks
A reference mark (read as a bookmark) was written as a bookmark only,
so text:reference-ref found nothing; names the file had as reference
marks are written as both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 01:00:28 +00:00
Claude f4dfd34373 M-ODT gap 19: lists without an xml:id kept as read, given one
Older files' lists have no xml🆔 kept as read all the same, given one
on save so a list going on with it can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 00:34:28 +00:00
Claude 0ce44996ec M-ODT gap 19: hidden sections stay hidden; unedited lists written as read
Found checking 220 ODT files from other applications (local only):
- What a section hidden with text:display="none" holds is read as
  hidden text, so it stays hidden written without the section.
- A top-level list whose items are all as read is written as read
  whole (label spacing, picture bullets, list headers); a list going on
  with it names it; a section may begin or end with a list.
- A damaged package's media type isn't printed in its error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-04 00:20:20 +00:00
Claude 570408b04b M-ODT gap 19: every package file written has its manifest entry
A kept part without a media type (LibreOffice's empty accelerator
configuration, a file the manifest didn't list) was written without an
entry, and LibreOffice refused the saved file. ODF 1.3 Part 2 §4.3
lists every file; its media type stays empty as read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 23:04:31 +00:00
Claude 69b457f155 M-ODT gap 19: bookmarks, notes, objects and page-anchored frames written as read
- Paragraphs and tables holding bookmarks are kept; the writer takes
  the names kept markup begins and ends, so either end may be as read.
- Footnotes and endnotes: each note's text fingerprinted when read;
  kept while it and the note numbering are as read.
- Formulas, charts and other objects in kept markup, with their files
  and preview pictures (only while that markup is written).
- Page-anchored frames LibreOffice puts in the text before a paragraph
  are kept before its markup.
- Styles of different families sharing a name are kept apart.
- The text keeps text:use-soft-page-breaks while kept markup holds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 21:09:10 +00:00
Claude 54988ddfda M-ODT gap 19: paragraphs with text boxes and shapes holding text written as read
The paragraphs a drawing's text holds count as its story's, so the
paragraph showing it can be kept. Each text box's and shape's story is
fingerprinted when read (with the boxes and notes it holds); one edited
sends its paragraph to the model. Linked boxes and a comment inside a
box's text stay with the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 19:40:12 +00:00
Claude 4edb16a946 M-ODT gap 19: paragraphs with comments and tracked changes written as read
A paragraph whose comments (range, replies, end) or tracked changes
(start and end) are all its own is kept as read. Each top-level comment
is fingerprinted with its replies when read; one edited, resolved,
answered or deleted sends its paragraph to the model, as a reply written
elsewhere in the file does. The change regions kept markup marks are
written as read (those their deleted content marks first), beside
WordCraft's own, which take the writer's prefix; one without a date is
written from the model. An older file's region gets its ODF 1.3 xml:id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 18:52:52 +00:00
Claude 7f6d9fbb97 M-ODT gap 19: paragraphs and tables with pictures and shapes written as read
A paragraph or table holding pictures and shapes without text is kept
as read while unchanged, with the pictures it shows (only those linked
from markup actually written, so a deleted picture stays deleted) and
its frames' styles. Text boxes, shapes with text, objects and controls
stay with the model. The file's Graphics and Frame styles are written as
read; WordCraft's own pictures don't take the file's picture names. The
flat writer inlines a picture whatever form its element has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 18:09:01 +00:00
Claude d746e11f65 M-ODT gap 19: progress log numbers for sections inside text boxes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 17:25:21 +00:00
Claude d6fb543e31 M-ODT gap 19: sections inside text boxes written as read
A section at a text box's top level keeps its wrapper, like the body's;
the box's story closes whatever it opened, so an edit inside the box
can't leave a section open past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 17:25:02 +00:00
Claude 0592a70d7e M-ODT gap 19: keep same-named definitions apart, sections around tables, chart replacements, note citations, stacking order
- Kept definitions are keyed by name space (a list style may share a
  paragraph style's name, §16.2), so unused list styles survive a save.
- Section wrappers kept as read go around tables as well as paragraphs,
  nested ones too, balanced however the blocks are edited.
- An unchanged chart keeps its replacement picture.
- Note citations use the document's number format and first number.
- draw:z-index is read into the float's stacking order and written back.
- The no-edit gate counts elements, catching a section of three lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 16:51:23 +00:00
Claude 2158d7388f M-ODT gap 19: progress log for forms and indexes kept as read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 15:17:29 +00:00
Claude 67b3bbfdb0 M-ODT gap 19: form definitions and indexes written back as read
The file's office:forms keeps its own head and each form:form with what
WordCraft doesn't model; controls are merged with the file's own. An
index whose generated entries nobody edited is written as read, with
its source settings and entry templates; an edited one is written from
the model. The no-edit gate saves nine samples of our own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 14:57:28 +00:00
Claude eb8d195188 ODT: gradient angles by the file's version; deviation from the standard documented
The standard (§18.3.1, since ODF 1.2) reads an angle without a unit in
degrees. WordCraft read unitless gradient angles in tenths of a degree in
every file; in ODF 1.3 files it now follows the standard.

LibreOffice and OpenOffice wrote gradient and hatch angles in tenths of a
degree in their files before ODF 1.3 and still read them so; read by the
standard, every older LibreOffice document's gradients would turn the
wrong way. So in files older than 1.3 they are still read in tenths: a
deviation from the standard, out of necessity, now listed in
docs/odf-interpretation.md (a new rule and section for such deviations).
WordCraft always writes these angles with a unit.

The ODF 1.2 -> 1.3 changes (ODF 1.3 Part 3 Appendix G and their OASIS
issues) were checked: none other gives a value written for 1.2 another
meaning; the new defaults match how LibreOffice already read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 13:56:31 +00:00
Claude fdbc3322b3 ODT: unedited tables, pages, sections and properties written as read; no-edit loss gate
More of what WordCraft doesn't change is written back as it was read:
tables; the document properties it doesn't model (template, statistics,
editing time, printing); the declarations opening the text and the
document's scripts; page layouts and master pages while the page setup
is unchanged, each section starting with the master page it started
with; named sections around unedited paragraphs. Package files and
folders kept markup names (a style's fill picture, a header's embedded
object) are written with it, with their manifest entries.

Files are saved as ODF 1.3; markup kept from an older file is brought to
1.3's meaning (a gradient's or hatch's unitless angle was tenths of a
degree). WordCraft's own drawings state style:flow-with-text, which the
standard leaves open.

odt::tests_more::lossless saves seven samples of our own without edits
and fails on anything lost (nothing is allowed), and checks that edits
keep the saved parts well-formed.

Over the 133-file corpus the round-trip difference falls from 191k to
51k pixels; 95 files come back pixel-identical (62 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 13:24:09 +00:00
Claude c73e9e0e87 ODT tests: only samples of our own
A placeholder test used the label <Title>, which LibreOffice's letter
templates also use; it now uses an invented one. The kept-markup tests
say that every sample (and any sample file the no-edit loss gate adds)
is written by us, never taken from someone else's files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 11:06:26 +00:00
Claude a6ba7321db ODT: what isn't edited is written back as it was read
A paragraph nothing changed since it was read (by a fingerprint of how it
was modelled) is written with its own markup, unknown elements and
attributes included, together with the automatic styles, common styles,
font faces, namespaces and package files it names. While no style or list
definition changed, styles.xml's office:styles is the file's own (unused
list styles, note and line numbering configuration, attributes WordCraft
doesn't read); drawing definitions and the default graphic style stay
WordCraft's, as drawings are written from the model.

Paragraphs holding drawings, comments, tracked changes or ranged marks,
or starting a page, are written from the model, as is everything edited.
Kept markup lives in memory only (wordcraft_doc::kept, 64 MB at most) and
is never part of the document's content.

Over the 133-file corpus the round-trip difference in LibreOffice falls
from 528k to 191k pixels; 62 files come back pixel-identical (46 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 10:39:40 +00:00
Claude 1eff715f10 ODT: author fields that follow the document say text:fixed="false"
LibreOffice reads an author field without text:fixed as fixed (the
standard says not fixed) and writes false itself; an empty author field
stayed empty after a save instead of showing the author. Sender fields
are written as LibreOffice writes them, without the attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 08:44:28 +00:00
Claude ca2cf4e0fd ODT: default text colour; covered cells' content stays out of the body
The default paragraph style's text colour (fo:color) is read into the
document's default and written back: Google Docs' grey résumé came back
black. What a covered table cell holds (not shown, §9.1.5) is skipped;
it went before the table as empty paragraphs, pushing ONLYOFFICE's table
down the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 07:02:10 +00:00
Claude 6f6559aacb ODT: widow control as a profile case; tabs measured from the indent kept
Widow and orphan control a file doesn't state is open in the standard
(it refers to XSL, whose initial value is 2); LibreOffice reads it as
off. It is now a profile case (widowControl), and WordCraft writes it on
its default style either way.

LibreOffice measures tab stops from the paragraph's indent when its
TabsRelativeToIndent setting is on (its default), default stops too
(measured). WordCraft's layout now counts default stops from the indent
for such documents, and writes them back measuring from the indent
instead of from the margin, which moved LibreOffice's default stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 06:18:46 +00:00
Claude faac2c42bd ODT: span kerning, note reference formatting, notes starting with a tab
Kerning turned on or off span by span (style:letter-kerning) is read and
written where it differs from the document's default; without it
LibreOffice set ONLYOFFICE's text wider and every page broke elsewhere
after a save. A note reference keeps its own formatting (a span around
text:note), and a note starting with a tab keeps it: only the space
WordCraft puts after the mark is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 05:33:13 +00:00
Claude 08dd0906ca ODT: fixed fields as locked fields with their values; sender fields
A fixed date or time (text:fixed) opens as a locked field and is written
back fixed, with the value its text shows under its picture
(text:date-value / text:time-value), which LibreOffice needs to keep it.
One whose text can't be read back is written as text. Sender fields open
as SENDER fields and are written back as text:sender-*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 04:40:16 +00:00
Claude 7160f05014 M5 #19: ODT user fields stay user fields
User fields (text:user-field-decl, text:user-field-get: LibreOffice's
report templates, 58 documents of the corpus) were read as custom
document properties and written back as meta:user-defined properties
with text:user-defined fields: the look and values stayed, the kind
didn't. Custom properties now remember being user fields
(CustomProp::odf_user_field) and are written back as
text:user-field-decls with their values and text:user-field-get fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 03:54:32 +00:00
Claude 6601aeaf09 M5 #19: ODT font declarations and change metadata kept
Found by a loss check comparing what a file uses with what a WordCraft
save writes back (over the 133-document local corpus):

- Every font a file uses is declared (office:font-face-decls, §16.21:
  family, generic family from its name, pitch) and named where it is
  used (style:font-name and its Asian and complex forms beside the
  fo:/style: font families), so a reader without the font picks a like
  one. Lost in 132 documents.
- meta.xml keeps who changed the document last (dc:creator), when
  (dc:date) and how often it was saved (meta:editing-cycles), read into
  and written from CoreProps. Lost in 120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 03:32:04 +00:00
Claude fdbe410704 M5 #19: ODT headings keep their style's look; widow control stated
Found with Google Docs' and ONLYOFFICE's ODT files (now in the local
corpus):

- A heading's look comes from its style, its outline level from its
  element: a text:h without text:outline-level (level 1, §19.850.4) in a
  style based on Heading 2 looks as Heading 2, as LibreOffice draws it,
  and keeps outline level 1 (it became Heading 1). The level is carried
  apart from the style (ParaProps::outline_level through the flow) and
  written back as text:outline-level.
- Widow and orphan control is stated in the default paragraph style
  (fo:widows/fo:orphans 2) when Normal has it: LibreOffice reads none
  unsaid as off, so pages broke differently.

Google's 2011 Fundamentals export now round-trips with its 88 pages and
its text (1.27M -> 139k differing pixels).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 01:58:04 +00:00
Claude 75ee5e6a43 M5 #19: docs: known ODT layout differences between WordCraft and LibreOffice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 00:51:04 +00:00
Claude 48ff99e52c M5 #19: LibreOffice reads WordCraft's ODT settings; paragraph spacing setting
Found by comparing WordCraft's own documents in LibreOffice and
bisecting settings.xml (observed only, black-box):

- The settings set's name, ooo:configuration-settings, is a qualified
  name: without xmlns:ooo declared LibreOffice ignored every setting
  WordCraft wrote (its Word layout rules, tab measuring).
- A set LibreOffice reads must be complete: a setting missing from it
  gets its old pre-OpenOffice value, which changes the layout. WordCraft
  now writes LibreOffice's current layout defaults (as a new LibreOffice
  document has them), overlaid with Word's layout rules for documents
  WordCraft lays out as Word does (not for ones opened from ODF), and
  its own choices.
- How the space between paragraphs adds up follows a file's
  AddParaTableSpacing (false: the larger, as Word); unstated, the ODF
  profile's new sumParagraphSpacing (WordCraft: the larger, LibreOffice:
  the sum). WordCraft writes it as it lays the document out.

WordCraft-origin documents in LibreOffice: 142k -> 80k differing pixels
over 42 documents; the LibreOffice corpus: no regressions, 40k -> 39.6k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 00:49:05 +00:00
Claude e635c8bd96 M5 #19: ODT cell background pictures
LibreOffice's elegant agenda puts a small gradient picture behind cells
(§17.3 style:background-image in a table-cell style). Cells can now
carry a background picture (CellProps::picture: media, own size,
repeat none/tile/stretch, alignment), which layout draws over the
cell's shading and under its text, clipped to the cell (Placed::Clip of
Placed::Image; tagged as an artifact in PDF). The ODT reader reads it
(style:repeat, style:position, the picture from the package) and the
writer writes it back with the picture in the package (once per
picture).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-03 00:10:57 +00:00
Claude cef4338947 M5 #19: ODT page border padding beyond 31 pt kept
The padding inside an ODT page border (§20.210) was capped at Word's
31 pt border spacing when opening, so the text of WordCraft's own page
borders (24 pt from the edge, 1 inch margins: 45 pt of padding) moved
towards the border on reopening. In ODF the padding is the distance to
the text, any length on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 23:45:28 +00:00
Claude a330e4c503 M5 #19: ODT watermarks written and read
WordCraft's watermark was left out of ODT files. It is now written as
LibreOffice and Word write theirs: a fontwork custom shape
(draw:text-path, draw:type fontwork-plain-text) named
PowerPlusWaterMarkObject, behind the text and centred on the page, in
every header (a header made for it alone fits inside the top margin, so
the text doesn't move). It doesn't grow to its text (draw:auto-grow-*
false), which LibreOffice needs to stretch the text to it. Such a shape
in a header, from LibreOffice or WordCraft, opens as the document's
watermark (text, colour, see-through, turn, size), not as a shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 23:36:02 +00:00
Claude 5063000781 M5 #19: ODT keeps table styles, SmartArt and section column balance
Found by saving WordCraft's own documents as ODT and opening them in
LibreOffice (42 documents: templates, themes, one per feature).

- Table styles: ODF has no table styles of Word's kind, so each cell now
  states what the style gives it (fills, its borders, bold header text,
  its paragraph spacing), under the paragraph's own styles as layout
  applies them (ECMA-376 §17.7.2; StyleSheet::table_para_patch and
  table_char_patch). The style's border mapping moved to
  styles::part_borders, shared with layout.
- Tables read from ODT no longer get Word's Table Grid style, whose
  single spacing changed their text.
- Columned sections say whether their columns end balanced
  (text:dont-balance-text-columns, §20.428, read as its name says; its
  value descriptions contradict it). Unstated, the ODF profile decides
  (balanceSectionColumns: LibreOffice balances, WordCraft as Word).
  SectionProps::balance_columns carries it to layout.
- SmartArt diagrams are saved as a draw:g of their shapes and text (with
  an invisible frame keeping the graphic's box), which LibreOffice draws
  as WordCraft does.

LibreOffice round-trip corpus: 40k differing pixels over 125 documents
(was 76k), 42 identical; WordCraft-origin documents 277k -> 145k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 23:24:24 +00:00
Claude 7b25253286 M5 #19: unstated page margins are an ODF interpretation setting
ODF 1.3 §20.206–20.209 define page margins by referring to XSL's
(§7.10), without saying whether XSL's initial value 0 comes with the
reference; LibreOffice uses 2 cm. The margin a page layout leaves out
is now the profile's `pageMargin`: WordCraft 0 (XSL's), LibreOffice
2 cm, or custom (File › Options › Advanced › OpenDocument files,
`file.options` `odf.pageMargin`). A stated margin is the file's under
every preset. With the LibreOffice preset, LibreOffice's CV, sans-serif
business letter and one-page résumé templates match LibreOffice again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 22:31:49 +00:00
Claude ffa309c266 M5 #19: OpenDocument preset as a drop-down; header spacing kept by default
- File › Options › Advanced › OpenDocument files: the preset is a
  drop-down like Measurement units and Region; shape line width is in
  points (a 0.25 pt hairline showed as 0.00").
- WordCraft's preset keeps a header's or footer's stated spacing when
  its style doesn't say whether it is dynamic: with no height stated,
  growing into it always dropped the spacing the file states. Custom
  profiles can still grow into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 21:43:26 +00:00
Claude 8d5485c8bd M5 #19: ODF interpretation profile (WordCraft / LibreOffice / custom)
What ODF 1.3 defines, including XSL's initial values for fo: properties,
is followed whatever the settings: unstated page margins are now 0, and
padding without a border takes room in every ODT document. What the
standard leaves to the application (Part 3 §16.2) is an OdfProfile:
WordCraft's own choices by default, LibreOffice's, or custom values.
The profile fills in only what a file leaves unsaid; stated values win
under every preset (tests_more/profile.rs).

Cases: header height, how its height and spacing combine, dynamic
spacing, tabs from the indent, column gap, shape fill, line and
hairline, page frames against a paragraph, in-line frames against a
paragraph or page, and lines of only pictures.

- doc: odf_profile module; Settings::odf (OdfLayout) replaces odf_layout
- formats: import_with / import_flat_with take a profile
- engine: Prefs::odf, file.options `odf`, opened files follow it
- ui: File > Options > Advanced > OpenDocument files (presets, custom cases)
- cli: WORDCRAFT_ODF=wordcraft|libreoffice
- docs: odf-interpretation.md lists every case (law, open, writer
  choices, Google Docs and ONLYOFFICE findings); parity docs, gaps,
  ROADMAP updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 20:54:05 +00:00
Claude bc74bc23f5 Gap 19: ODT form controls keep their place and lines; custom shapes their geometry
Form controls:
- Placement: a control hanging from the baseline (as LibreOffice places
  those it makes) was saved with no placement. LibreOffice then puts it
  at the line's top, so every control row shrank. Its raise is now kept
  (`ContentControl::form_raise`, read as in-line frames are) and
  written, standing on the baseline when it had none.
- Multi-line fields: these were saved without LibreOffice's `MultiLine`
  property, so they showed one centred line. They now carry it.
- Text fields get LibreOffice's flat border.
- Also: `pt()` no longer writes "-0pt".

Custom shapes:
- A shape drawn from its own geometry was saved as WordCraft's path of
  it, and a preset WordCraft draws as a rectangle (a cloud) was saved as
  a rectangle. The geometry as read (type, path, equations, handles) is
  now kept (`ShapeExtra::odf_geometry`) and written back, mirrored as
  the shape now is.
- `shape.change` and the ink-to-shape conversion drop the kept geometry,
  since the shape is then another kind. `ShapeExtra` holds it as an
  `Arc<String>` to keep `Placed::Shape` small.
- The reader's `end` hands skipped elements to `end_skipped`.

Results:
- The forms document round-trips pixel-identical (2,949 pixels before),
  with its text the same.
- The shape presets fall from 2,467 to 1,220. The rest is LibreOffice's
  hairline outline, which WordCraft keeps as 0.5 pt.
- Across the corpus the round-trip difference falls from 45k to 41k
  pixels. 42 of 125 documents round-trip pixel-identical, 92 with their
  text the same, and none gets worse.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 19:49:57 +00:00
Claude 7cdc8d85a4 Gap 19: ODT in-line frames placed against the text; saved on the baseline
Found with LibreOffice's style templates.

- An in-line frame placed against the text (`style:vertical-rel` "text"
  or "char", with `middle`, `top` or `bottom`) stood on the baseline. It
  is now raised or lowered against the text's height, from the size of
  the text it sits in. Measured in LibreOffice, `middle` centres the
  frame on the text: its middle half the ascent less the descent above
  the baseline. Placement against the line still stands on the
  baseline.
- An in-line picture or chart was saved with no placement style. In
  LibreOffice that puts it at the line's top (measured: the title beside
  the Finances logo moved up 19 pt). It is now saved standing on the
  baseline ("top" to "baseline"), as WordCraft and Word show it.

Two templates' round-trip differences fall from 1,874 and 1,433 pixels
to 521 and 586. Across the corpus the difference falls from 47k to 45k,
and no document gets worse.

Left in these templates:
- template instantiation: LibreOffice refreshes a template's fixed dates
  and fills its sender fields from the user's data when opening it;
- a first-page footer's frame placed against the page content;
- a page background picture centred in the margins rather than on the
  page.
gaps.md row 19 lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 18:39:08 +00:00
Claude 90898f454b Gap 19: ODT charts keep their look; frame outlines; default header height
Charts: on save, WordCraft wrote each chart anew from its chart model
(type, data, title, legend, labels), losing the look it doesn't model:
fonts and sizes, axis and grid lines.
- The chart object as read is now kept with the chart (`Chart::source`
  in the flow model, `Graphic::source` in the document).
- `chart::redraw` keeps it when the chart is drawn again on open or
  resize.
- The ODT writer writes it back whole while it still reads as the same
  chart. An edited chart is written from the model, as before; DOCX
  still writes charts from their model.
- The chart documents now round-trip pixel-identical (chartcol: 5,917
  pixels to 0).

LibreOffice's serif business letter (4,223 pixels to 116):
- A text frame outlined by a drawn stroke (`draw:stroke`, a 0 width a
  hairline) and no `fo:border` lost its outline. It is now the frame's
  line; an explicit "none" border still wins.
- A header or footer whose style states no height, or that has no style,
  was 0 tall. LibreOffice makes it 0.5 cm (measured by varying the
  height), so the text had moved up 1.5 pt.

Across the corpus the round-trip difference falls from 60k to 47k
pixels, and 41 of 125 documents round-trip pixel-identical. No document
gets worse.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 17:11:20 +00:00
Claude 807fe91724 Gap 19: ODT tables keep header rows only where they repeat
Found with LibreOffice's agenda templates. Saving wrote a table's first
row as `table:table-header-rows`, which LibreOffice repeats at the top
of each page, so a table crossing a page showed its first row twice
("<bring>" twice). The cause: the flow model marks a first row as a
header when the table style's look has a header row, which Word's look
has by default. That only affects the style's formatting, and HTML and
Markdown still rely on it for heading cells.

- The flow table now carries how many rows repeat
  (`TableExtra::header_rows`, Word's Repeat as header row).
- The ODT writer writes only those rows as header rows.
- That extra block is now always kept for ODT flows, where it had been
  dropped from tables without an alignment, width or row height.

All 16 agenda templates now round-trip with their text the same; the
modern and simple ones fall from about 5,200 differing pixels to 145.
Across the corpus, documents with the same text after a round trip rise
from 75 to 91 of 125, and the round-trip difference falls from 82k to
60k pixels.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 16:11:20 +00:00
Claude be2666da15 Gap 19: ODT padding without a border, unequal columns, column breaks
Found with LibreOffice's report templates and one-page résumé.

Padding without a border:
- LibreOffice gives a paragraph's padding room on every side, with or
  without a border; measured, 0.5 in of padding and no border moves the
  text 0.5 in in and down. WordCraft dropped it.
- The reader now keeps it (an invisible border holding the space; a new
  `Borders::takes_room()`), and the writer writes it back.
- The layout adds it to the spacing or indent under the document
  setting, now renamed `Settings::odf_layout` to cover all of
  LibreOffice's layout rules. `ResolvedPara::padding` carries it, so
  Word documents are unchanged.

Columns:
- Unequal columns (`style:column` relative widths and indents, on pages
  and sections) were made equal. They now keep their widths and are
  written back as shares. The writer leaves out `fo:column-gap` beside
  them, because LibreOffice ignores a page's column widths when a gap is
  stated (checked).
- Columns with no stated gap have none, where they had Word's 0.5 in.
- A paragraph starting a column (`fo:break-before="column"`) now starts
  with Word's column break, written back on the paragraph.

The report templates round-trip to 0-11 differing pixels (from up to
9,600), and the résumé to 24 (from 6,286) with its text the same.
Across the corpus the round-trip difference falls from 155k to 82k
pixels. WordCraft's own rendering difference falls from 469k to 428k,
and no document gets worse.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 15:39:32 +00:00
Claude 959770fa13 Gap 19: ODT lines of only pictures are as tall as the pictures
LibreOffice sizes a line holding only in-line pictures by the pictures
alone. Its font's ascent and descent don't count: measured in
LibreOffice, a 3.6 pt rule's line is 3.6 pt, where WordCraft (following
Word) made it 15.5 pt.

- A new document setting, `Settings::object_lines_fit_objects`, makes
  the layout size such lines by the pictures alone. The ODT reader turns
  it on, so other documents keep Word's layout. An equation or any text
  on the line keeps the font's height.
- A document with no page layout at all (no styles.xml) opened on Word's
  Letter page with 1 inch margins. It now uses the region's paper with
  LibreOffice's 2 cm margins.

WordCraft's own rendering of LibreOffice's elegant agenda comes close to
LibreOffice's: its pixel difference halves, from 17,428 to about 8,950.
Across the corpus WordCraft's rendering difference falls from 487k to
469k. No document gets worse, and round trips are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 14:51:51 +00:00
Claude adfcbcbf7c Gap 19: ODT pictures alone in a paragraph keep its size and their raise
Found with LibreOffice's elegant agenda:
- Paragraph size: a paragraph holding only pictures lost its own font
  size, since the flow model's picture runs carry no formatting, so its
  line grew. Such a paragraph (`Para::textless`) now keeps its formatting
  as the paragraph mark's, which its pictures' runs take too. Read and
  written as for empty paragraphs.
- Raise: an in-line frame's raise was dropped when a parent style gave
  it a relation no in-line frame has (the Graphics style's
  `style:vertical-rel="paragraph"`). It is now measured from the baseline,
  as LibreOffice reads it.

The agenda's round-trip difference falls from 6,542 to 1,331 pixels, and
two business fax templates' from about 5,300 to 270 and 350. Across the
corpus it falls from 185k to 155k. No document changes page count.
WordCraft's own rendering of a picture-only line is still taller than
LibreOffice's, a layout difference left for later.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 14:07:54 +00:00
Claude 8f65b867c0 Gap 19: ODT tables fit the margins and keep their spacing; header spacing kept
Found with LibreOffice's report templates.

Tables:
- A table filling the margins (`table:align="margins"`) but wider than
  them ran off the page. It is now fitted to the text width, its columns
  in proportion. Checked in LibreOffice, which fits such a table with or
  without relative column widths; the old test's claim otherwise is
  corrected.
- A table's own space above and below (`fo:margin-top` and
  `fo:margin-bottom` on the table) was dropped. It is now read into new
  `TableProps::space_before`/`space_after`, laid out (added to the
  spacing of the paragraphs around it, as LibreOffice does) and written
  back.

Headers and footers:
- A header taller than its band lost its spacing from the body, moving
  the text up. LibreOffice keeps the spacing below the content unless it
  is dynamic (`style:dynamic-spacing`, which behaves as Word does). New
  `SectionProps::header_gap`/`footer_gap` hold it. The layout keeps it
  past a tall header or footer, and the writer writes it back. Word keeps
  no gap, so DOCX documents are unchanged.

Across the 125-document corpus, the round-trip pixel difference falls
from 241k to 185k, and WordCraft's own rendering's difference from
LibreOffice's falls from 562k to 484k. No document changes page count or
gets worse; one report template falls from 7,646 to 602 pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 13:38:36 +00:00
Claude 2f08e8bb01 Gap 19: ODT frames take their anchor and place from their style
A frame that doesn't state its anchor or position now takes them from
its graphic style chain (ODF lets `style:graphic-properties` carry
`text:anchor-type`, `svg:x` and `svg:y`).

LibreOffice's business card template relies on this:
- its Labels style holds the card in line;
- its Graphics style puts the credit at the top of the page.

WordCraft had floated the card, so a save scrambled the layout and added
a page.

Across the 125-document corpus, no document now changes page count
through a save (3 did), and the round-trip pixel difference falls from
333k to 241k. gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 12:51:36 +00:00
Claude 3164c33c28 Gap 19: ODT letters keep one page: default size, page frames, box styles, empty lines
Saving LibreOffice's letter and fax templates added a page and moved
their logos. Fixes:
- Default font size: the writer always wrote the default style's size as
  12 pt. It now writes Normal's size (11 pt in these letters).
- Page-anchored frames: a frame placed "from the paragraph" was moved by
  the page margin. A page has no paragraph, so it is now placed from the
  page, and "from the paragraph's content" from the page's text area, as
  LibreOffice places it.
- Text box styles: a custom style used only by paragraphs in text boxes
  (the fax's centred "Frame contents") was dropped. The style pass now
  walks text boxes and groups.
- Empty paragraphs: an empty paragraph's own font size (its height) was
  dropped. It is now kept as the paragraph mark's formatting, a new
  field in the shared flow model that the ODT reader and writer use.

The marine, fax and bottle letters keep one page after a save. Over the
125-document corpus the round-trip pixel difference falls from 408k to
333k, and the business letters' differences roughly halve.

Also moved a doc comment that 48c60a5 left on the wrong function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 12:05:17 +00:00
Claude 6a62c6142f Gap 19: docs: variables, emphasis marks, page backgrounds, tables, default size
gaps.md row 19 now lists what's left in ODT:
- turned characters;
- conditional text;
- control looks;
- embedded spreadsheets' contents;
- dates in non-US locales.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 11:09:38 +00:00
Claude 48c60a51b1 Gap 19: ODT default font size; dates in the locale's order
- The document's default font size (`fo:font-size` in
  `style:default-style`, or the Standard style's: the document's
  defaults) was ignored, leaving 12 pt; LibreOffice's letters are
  10 pt. It is now the document's default. A named style with no parent
  keeps the default style's size, as ODF inherits it.
- A date style ordered by the locale (`number:automatic-order`) showed
  its parts as listed: "2026November2". It now shows in US English
  order: "November 2, 2026", or "11/2/2026" with a numeric month, as
  LibreOffice shows it here.

WordCraft's own rendering of LibreOffice's letter templates comes much
closer: one letter goes from 11,377 to 6,237 differing pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 11:09:20 +00:00
Claude 7be387dabc Gap 19: ODT page background pictures
A page layout's background picture (`style:background-image`, §17.3:
LibreOffice's report templates' world map) was dropped. It is now
WordCraft's page picture (Design › Page Color › Fill Effects) and is
written back with its picture:
- repeated: tiled;
- stretched: stretched;
- not repeated: centred at its own size, a new fill that the screen
  renderer and PDF export draw in the middle of the page. DOCX writes
  it stretched, as VML has no centred fill.

LibreOffice's report template with the map goes from 11,719 to 3,602
differing pixels, and shows the map after a save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 10:36:41 +00:00
Claude 5cdf2608bf Gap 19: ODT tables fixed layout and starting a page; a page break before a table's first row moves it
- A table starting a page (`fo:break-before="page"` on the table,
  LibreOffice's agenda minutes) didn't. Its first paragraph now has a
  page break before it, and it is written back on the table.
- Layout: a page break before the paragraph in a table's first row
  now moves the whole table to the next page, as Word does. It was
  ignored; this matters for Word documents too.
- ODT tables are now fixed layout (Word's `w:tblLayout`).
  LibreOffice's columns never fit their content.

LibreOffice's 6orange agenda now round-trips with the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 10:08:59 +00:00
Claude 078945e10c Emphasis marks: modelled, drawn, set, and kept in DOCX and ODT
Word's Font › Emphasis mark (a mark over or under each character, used
to stress East Asian text) wasn't modelled. DOCX `w:em` and ODT
`style:text-emphasize` were dropped.
- Model: `CharProps::emphasis` (dot, comma, circle, dot below, or none
  to turn off a style's).
- Layout: a mark is drawn over (or under) each character, and the line
  is made taller to fit it.
- `format.emphasisMark {"mark": …}` sets it, and Format state reports
  it. The catalog lists it under Home › Font.
- DOCX: `w:em` is read, and written in its place in `w:rPr` (after
  `w:cs`, before `w:lang`).
- ODT: `style:text-emphasize` maps to it: an accent is the comma, a
  disc the dot, a dot below the under-dot.

Also fixes two problems from the previous commit (ODT variables):
- Field updates blanked a SET field's stored result. The layout
  already draws nothing for SET, so its result stays as it was, as
  `ask_and_set_fields_draw_nothing` pins.
- A clippy lint (manual char comparison) failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 09:46:23 +00:00
Claude 56188b97f4 Gap 19: ODT variables as SET/REF fields; SET evaluated; character styles keep what they turn off
- ODT variables (`text:variable-set`, `text:variable-get`, §7.4) were
  read as their text. A set is now Word's SET field, followed by a REF
  when it is displayed; a get is a REF. They are written back declared
  (`text:variable-decls`), the sets hidden with a get after them.
- The engine now evaluates SET (ECMA-376 §17.16.5.58). It shows
  nothing and gives its name a value. REF to a name no bookmark has
  shows that value; a REF before the SET shows the last value set, as
  Word's bookmark does after an update. This works for Word documents
  too.
- Custom character styles now keep what they turn off under a parent
  that turns it on (`fo:font-weight="normal"`…), as paragraph styles
  already do.

A LibreOffice document with variables round-trips with the same text,
and the saved file validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:37:37 +00:00
Claude bc5d9f9fa5 Gap 19: docs: ODT checked against LibreOffice's own templates
gaps.md row 19 now lists what's left in ODT after the template work:
- emphasis marks and turned characters, which have no WordCraft model;
- variable fields;
- control looks;
- embedded spreadsheets' contents;
- tables wider than the margins;
- what a character style turns off.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:02:33 +00:00
Claude e2b439ed17 Gap 19: ODT metafile pictures, pictures in text boxes, styles that turn things off
More from LibreOffice's bundled templates:
- WMF and EMF pictures (LibreOffice's fax and letter logos) were
  dropped. They now open as pictures, at their own size (a placeable
  WMF's or an EMF's frame) when the frame gives none, and are saved
  with their media types.
- A floating picture inside a text box, saved by WordCraft, didn't
  show in LibreOffice. Picture frames' styles now derive from the
  Graphics style (declared, like Frame), which LibreOffice needs to
  show it.
- A named paragraph style turning off its parent's bold (agenda
  dates under bold titles) showed bold, both in WordCraft and in
  LibreOffice after a save. What a style turns off (bold, italic,
  underline, strike-through) is now kept and written.
- Columns' shares (`style:rel-column-width`) are now written only for
  tables filling the margins. LibreOffice lays out an aligned table
  oddly with them: WordCraft's sample table lost its proportions.
- Drawings waiting for a paragraph now join only a paragraph of the
  text itself, never one in a text box or note.

With the harness's paper setting fixed (it had made LibreOffice draw
unsized pages as Letter), most of the 78 templates compare much closer
page by page. The CV template and the contour sample are now
pixel-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 08:02:11 +00:00
Claude 1dd4e64191 Gap 19: ODT page margins default as LibreOffice's; loose drawings join a paragraph
More fixes from LibreOffice's bundled templates:
- A page margin the file leaves unsaid was 1 in, and a page layout with
  no properties at all was ignored. Both now take LibreOffice's
  defaults: 2 cm margins (observed black-box). LibreOffice's CV template
  now lays out as LibreOffice draws it.
- Drawings anchored outside any paragraph (LibreOffice's
  page-anchored frames before the first paragraph) made a paragraph of
  their own. That paragraph started the document on the default page
  style rather than the one the first paragraph asks for, which put the
  running header on a letter's first page and pushed the letter onto
  two pages. They now join the next paragraph; before a table or at the
  end, they still get a paragraph of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 07:03:12 +00:00
Claude ab02c85701 Gap 19: ODT fixes found by round-tripping LibreOffice's own templates
LibreOffice ships 78 text templates (letters, faxes, agendas, reports,
CVs). Round-tripping them through WordCraft exposed these problems:
- Placeholders (`text:placeholder`, "<Recipient's name>") were
  printed text. They now open as plain-text content controls showing
  them, so a click selects one and typing replaces it. Their
  description becomes the control's title. They are written back as
  placeholders of their type while they still show; what was typed is
  written as text.
- A fixed date with neither a value nor text was lost. It is now a date
  field.
- `text:author-name` opens as USERNAME, which the engine left empty.
  USERNAME and USERINITIALS now show the user's name and initials
  from File › Options; a quoted text after the name is shown instead.
- A "First Page" master followed by another (`style:next-style-name`)
  put the running header on the first page. It is now a section with a
  different first page: its own (empty) header on page 1, the next
  master's after that.
- A page layout without a size was Letter. It is now the default
  page's, the user's region's, as LibreOffice uses its default paper.
  ODT import takes the regional page like the other importers.
- Style-only templates (no `content.xml`) failed to open. They now
  open empty with their styles.
- Table columns are now written with their share of the table
  (`style:rel-column-width`) too. A table filling the margins of a
  narrower page keeps its proportions in LibreOffice.

All 78 templates now open and round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 06:36:36 +00:00
Claude 7848757954 Gap 19: ODT hidden form controls kept; .ott templates opened and saved
- Hidden form controls (`form:hidden`: a name and value the form sends,
  not shown) were dropped. They are now kept verbatim, with their
  properties, and written back into the form with ids of their own.
- OpenDocument text templates (`.ott`) weren't opened. They now open
  as documents, and save as templates: the template's media type goes
  in `mimetype` and in the manifest's root entry (ODF 1.3 Part 2 §3.3).
  This works with a password too. `.ott` is listed wherever `.odt` is:
  open and save formats, file dialogs, export, the web app and its
  media type.

A LibreOffice template round-trips with the same text. 300 fuzzed
documents exercising custom shapes, cell formulas, kept parts, turned
text and forms caused no crashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 05:49:16 +00:00
Claude ce516b04f6 Gap 19: docs: ODT fidelity re-estimated at ~90%
After custom shapes, cell formulas, kept package parts, turned text,
and control sizes and buttons: ODT is ~90% in file-format-parity (20–35
h left). gaps.md row 19 lists what's left in ODT:
- emphasis marks;
- turned characters;
- conditional and variable fields;
- hidden controls and control looks;
- embedded spreadsheets' contents.
The ROADMAP progress log has the entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:46:56 +00:00
Claude 78bd6b2b72 Gap 19: a kept ODT form control made another kind saves as that kind
Review fix. A button kept verbatim was still written as the button
after its content control was made another kind (a check box, a list).
The kept form XML is now used only while the control is still the
plain text control it was read as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:46:56 +00:00
Claude 693ccff3b2 Gap 19: ODT form control sizes and buttons kept
On save, form controls were sized to their text. Buttons and other
controls WordCraft has no kind for were dropped.
- Sizes: each control keeps the size of its `draw:control`
  (`svg:width`, `svg:height`), stored on the content control.
- Buttons, image buttons, labels, file and value-range controls, frames
  and generic controls now show their label, locked, as a content
  control. Their form XML is kept verbatim and written back as it was,
  so LibreOffice's button round-trips as a button.
- A kept control that names a namespace WordCraft doesn't declare is
  left out. So is one longer than 64 KiB.
- The `ooo`, `oooc`, `script` and `dom` prefixes that LibreOffice's
  controls use are declared, so the content validates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:36:34 +00:00
Claude cb7de0227f Gap 19: ODT turned text in table cells and text boxes
Text turned down or up opened across. This covered table cells and text
boxes (`style:writing-mode` §20.404, and LibreOffice's own
`loext:writing-mode="bt-lr"` for upward text).
- Table cells now open and save with WordCraft's cell text direction.
- Text boxes now open and save with their text direction.

LibreOffice's turned cells render as LibreOffice draws them. LibreOffice
reads WordCraft's turned text boxes back identically.

Turned characters (`style:text-rotation-angle`) still open upright:
WordCraft has no turned characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:15:00 +00:00
Claude 2b826a288c Gap 19: ODT macros and unknown package parts kept
Package parts WordCraft doesn't read were dropped on save. These
include LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`),
`manifest.rdf` and other applications' files. They are now kept as
they are and written back with their manifest entries. LibreOffice
finds a round-tripped document's macro intact; WordCraft never runs
macros.

Dropped on save: the old thumbnail, and digital signatures
(`META-INF/*signatures*.xml`), because they sign the package as it
was. Also dropped: paths that climb out of the package, and paths
WordCraft writes itself (pictures, embedded objects).

Kept parts are bounded: 1000 files, 64 MiB in all. They survive
password protection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 23:14:59 +00:00
Claude 2036ad2e99 Gap 19: ODT table cell formulas as Word formula fields
ODT cell formulas (`table:formula`) were lost, and only their value was
kept as text.
- Reading: LibreOffice's formulas, in its own syntax
  (`ooow:sum <A1:A3>`, `<B1>*<B2>+<B3>`, `mean`, `min`, `max`,
  `product`, `abs`, `sign`, `count`, `add`/`sub`/`mul`/`div`/`pow`, lists
  joined by `|`), open as Word formula fields (`=SUM(A1:A3)`) showing
  the cell's value. Update Field recalculates them.
- Writing: formula fields in cells are written back as cell formulas
  with their value. ABOVE, LEFT, BELOW and RIGHT become the cells they
  name.
- Cells holding a plain number are written as numbers, so LibreOffice
  calculates with them. The `ooow` prefix is declared; without it,
  LibreOffice reports "Expression is faulty".

A formula one side can't say keeps its value as text. A LibreOffice
table of sums round-trips with the same text, and the saved file
validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:57:17 +00:00
Claude 7c9d301541 Gap 19: ODT custom shape equations worked out once, points kept finite
Review fixes to the custom shape geometry:
- An equation that couldn't be worked out wasn't remembered. Equations
  each referring twice to the next, ending in one that refers to
  itself, took about 2^64 steps. Each equation is now worked out once,
  failures and those being worked out included.
- Points far outside the view box (1e300) became infinite as f32. They
  are now clamped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:57:17 +00:00
Claude 04ff4e3a54 Gap 19: ODT custom shapes drawn from their own geometry
A custom shape whose type WordCraft has no kind for (hexagon, octagon,
smiley, can, moon, lightning, block arc, cross, pentagon, flowchart
shapes…) opened as a rectangle. It is now drawn from its own geometry
(ODF 1.3 §10.6.2) as a freeform:
- the enhanced path (§19.145): moves, lines, curves, ellipses (T, U),
  arcs (A, B, W, V) and quarter ellipses (X, Y), arcs followed in
  short straight steps;
- its values worked out from the shape's equations (§10.6.3) and
  adjustment values: numbers, other equations, modifiers, the frame's
  keywords, + - * /, abs, sqrt, sin, cos, tan, atan, atan2, min, max
  and if.
Path length, equation count and nesting, and points are all bounded.

LibreOffice fills no part of a `draw:path` that has an open part. So a
freeform with both closed and open paths is now saved as a custom shape
whose path leaves the open ones unfilled (`F`).

LibreOffice's preset shapes render as LibreOffice draws them. The
saved file validates against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:37:45 +00:00
Claude 2877514d9e Gap 19: ODT bibliography marks as citations of sources
ODT citations (`text:bibliography-mark`) were read as their text, and
the sources they carry were lost. So an ODT bibliography had nothing
to update from.
- Reading: each mark is now a `CITATION` field of its source, which
  becomes one of the document's bibliography sources (the first mark
  of an identifier gives it). The identifier is the tag, the type the
  kind, plus author, title, year, publisher, address, journal, volume,
  pages, URL, editor, edition, number and month.
- Writing: a `CITATION` of a known source is written back as a mark
  carrying the source.

References › Update rebuilds an ODT bibliography from them. A
LibreOffice citation and bibliography round-trip pixel-identical and
validate against the schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:13:47 +00:00
Claude 27b7f34425 Gap 19: ODT linked text boxes stay linked
Linked text boxes (`draw:chain-next-name` on a `draw:text-box`, naming
the frame its text continues in) lost their link on open. The
following box's text was gone, since only the first box holds the
story.
- Reading: each box keeps its frame name and the name of the next box.
  Once all boxes are made, names are resolved to WordCraft's link
  (`TextBody::next`). A link to a missing box, to the box itself or
  round in a loop is dropped.
- Writing: each box is named after its story, and a linked box names
  the next one.

Flow model: `BoxLook::chain` (name, next).

A LibreOffice chain round-trips pixel-identical, and WordCraft flows
the text from the first box into the second. A linked box keeps its
own height where LibreOffice grows it to the rest of the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 22:02:39 +00:00
Claude 4c0bb99016 Gap 19: ODT picture crops and contour wrapping
Two picture properties were dropped.
- Crop: `fo:clip` (top, right, bottom, left, in lengths of the
  picture's own size, its pixels at 96 per inch) is now WordCraft's
  crop fractions. A picture's crop is written back as `fo:clip`, with a
  graphic style even for an in-line picture.
- Contour wrapping: `style:wrap-contour` with
  `style:wrap-contour-mode` `outside` is Word's Tight wrapping, and
  `full` (the default) is Through. The frame's `draw:contour-polygon`
  becomes the wrap polygon, scaled from its view box. A polygon whose
  points lie well outside its view box is in another coordinate system
  (LibreOffice writes one for some pictures), so the outline is
  derived from the picture instead. All of this is written back, the
  polygon last in the frame.

A cropped LibreOffice picture round-trips pixel-identical, and a
contour-wrapped one round-trips with WordCraft wrapping text as
LibreOffice does. Both validate against the schema. `Body::start`'s
frame parts moved to a helper (clippy's complexity limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 21:50:19 +00:00
Claude 2bc5874da0 Gap 19: docs: ODT fidelity re-estimated at ~85%
This round added flow breaks, table placement, ruby, index marks, text
borders, connectors and custom properties. A 20,000-paragraph document
round-trips in about a second with nothing lost, and 400 fuzzed
documents opened without a crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:38:11 +00:00
Claude b114209d2b Gap 19: ODT date properties kept as full UTC dates
Review of the custom property commit: an ODF date property may be a
bare date, while Word's `filetime` (which a DOCX save writes) wants a
UTC date and time. A bare date is now midnight UTC, a date and time
without a zone is taken as UTC, and a value that isn't a date is kept
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:32:04 +00:00
Claude 0b6b88f414 Gap 19: ODT custom document properties and user fields as DOCPROPERTY
ODT custom document properties (`meta:user-defined` in `meta.xml`) were
dropped, and the fields showing them or a user field were read as
plain text.
- Properties: `meta:user-defined` values (string, number, true/false,
  date) are now WordCraft's custom document properties, and so are
  user field declarations (`text:user-field-decl`).
- Fields: `text:user-defined` and `text:user-field-get` are Word's
  `DOCPROPERTY` fields, which WordCraft updates from those properties.
- Writing: custom properties go back into `meta.xml`, and a
  `DOCPROPERTY` naming one is written as `text:user-defined`.

A LibreOffice document with a custom property and a user field
round-trips pixel-identical, valid against the schema. Its user field
comes back as a document property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:30:48 +00:00
Claude 1e9c11fb1c Gap 19: ODT connectors in groups glued by their shapes' sites
Review of the connector commit: a connector inside a drawing group
wasn't converted. Its group's shapes weren't recorded, so its ends
lost their glue. Group members are now recorded for their kind (never
for placement, since they sit in the group's coordinates), and
connectors in groups have their glue points made their shapes' sites
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:18:10 +00:00
Claude 3d464f3ebe Gap 19: ODT connectors read and written glued
ODT connectors (`draw:connector`) were left out. They now open as
WordCraft connectors: straight (`line`), curved (`curve`), or with
bends (`standard`, `lines`).
- Gluing: each end is glued to the shape it names (`draw:start-shape`,
  `draw:end-shape`). The shape's four default glue points (§9.2.19:
  top, right, bottom, left) become its connection sites. An end glued
  to a glue point of the shape's own, or to a shape not read, is left
  unglued.
- Placement: a connector's own coordinates are in LibreOffice's drawing
  layer, not its anchor's. So a connector glued at both ends to shapes
  anchored in its paragraph is placed between their sites.
- Writing: connectors are written back as `draw:connector` with their
  type and glue, and the shapes they glue to get ids
  (`xml:id`/`draw:id`). LibreOffice re-routes them from the glue.

LibreOffice's connector document round-trips with its connectors
drawn between the shapes, and WordCraft draws them between the right
sites. Other formats still get connectors as straight lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 20:15:27 +00:00
Claude f683e308c6 Gap 19: ODT borders around text read and written
LibreOffice writes a border around text as `loext:border` (or its
sides) with `loext:padding`; ODF 1.3 has no attribute for it. It was
dropped. It is now read as Word's text border (one line all round, the
first side with a line, the padding its distance) and written back
the same way, which LibreOffice draws. A document with a text border
is then extended ODF rather than strict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:17:20 +00:00
Claude 5f7f89c187 Gap 19: docs: ODT status in gaps, roadmap progress log
Gap 19's row now says what ODT reads and writes and what is left, and
its estimate drops from 18–27 h to 10–16 h. The roadmap's progress log
covers the ODT work since text boxes: shapes through password
protection, forms, ruby and flow breaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:05:48 +00:00
Claude 72e9593de9 Gap 19: ODT index marks as XE fields; updating an index held around its entries
ODT index marks (`text:alphabetical-index-mark`) were dropped. So
References › Update Index on an opened ODT index found no entries and
left the old list in place. Now:
- a mark at a point is Word's `XE` field, its `text:key1`/`text:key2`
  the levels above its entry;
- a mark over text (`-mark-start` … `-mark-end`, in one paragraph) is
  an `XE` field whose entry is the marked text, which stays text;
- `XE` fields are written back as point marks.

Engine: Update rebuilt only lists in WordCraft's own form, a paragraph
holding the field. A list held as a field around its entries, as ODT
and Word files hold an index, bibliography or table of figures, is now
first made the field's own paragraph, then rebuilt. That happens only
when the field starts its paragraph, so no document text is replaced.

A LibreOffice document with an index mark round-trips pixel-identical,
the mark kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 19:04:44 +00:00
Claude 6dff74990e Gap 19: ODT ruby read and written
Ruby (`text:ruby`, ODF 1.3 §6.4: a base text with ruby text over it,
furigana or pinyin) was read as its base and ruby text run together,
and the writer wrote any ruby as plain text (its comment wrongly said
ODF has none).

Ruby is now read as WordCraft's Phonetic Guide. It is written back as
ODF ruby, above its base and centred, with an automatic ruby style.
A ruby inside a tracked change is still written as its base text,
which the change marks, so the tracking isn't lost. LibreOffice shows
the round-tripped ruby over its base, and the content validates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 18:43:42 +00:00
Claude 6925818110 Gap 19: ODT page breaks after, kept lines, widows, hyphenation, table placement
Six features of LibreOffice documents were lost on open and save. A
two-page document came back as one page, because the page break after
a paragraph was dropped. Now read and written:
- a page break after a paragraph (`fo:break-after="page"`), as a page
  break before the next paragraph, as Word has it;
- lines kept together (`fo:keep-together`);
- widow and orphan control (`fo:widows`/`fo:orphans`): on with any line
  count, off with none;
- hyphenation (`fo:hyphenate`): a paragraph's in ODF, the document's in
  WordCraft. A document with a hyphenated paragraph hyphenates, and its
  other paragraphs opt out;
- a table's alignment and width (`table:align`, `style:rel-width`);
- its rows' heights (`style:row-height` exact, `style:min-row-height` at
  least).

Flow model: `FTable::extra` (alignment, width, row heights), filled by
formats that keep them.

A LibreOffice document with all of these round-trips at 573 px
difference instead of 4,407, keeps its two pages, and validates
against the ODF 1.3 schema. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 18:30:41 +00:00
Claude b87c80cdc7 Layout: text in a turned text box turns with it
A turned text box (free rotation) drew its frame turned and its text
upright. Its text now turns with it about the box's centre, as Word
draws it, on screen and in PNG and PDF export: upside down when the
box is flipped across, never mirrored by a side-to-side flip.

PDF export draws item by item, to tag each paragraph, so it turns each
of the box's lines the same way, and tags the turned lines as their
paragraph's text rather than as decoration.

Clicks and the caret inside a turned box still go by its upright text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:42:07 +00:00
Claude 8506d9da54 Gap 19: ODT in-line placement, leading and text box borders as LibreOffice draws them
Three causes of the largest remaining LibreOffice round-trip
differences:
- In-line frames and shapes centred on or hanging from the baseline
  (`style:vertical-pos` `middle`, `bottom`, or `from-top` with
  `svg:y`, relative to the baseline) were read as standing on it. They
  are now read as their run's lowering, which the layout honours, and
  written back as `from-top`.
- Leading: LibreOffice also leaves the leading after a paragraph's last
  line. It is now added to the paragraph's space after.
- Text box frames didn't derive from LibreOffice's Frame style, without
  which LibreOffice ignores a frame's `fo:border` and outlines it in
  its default shape colour. They now do, and an empty Frame style is
  declared.

LibreOffice round trips (pixel difference): shapes 13,864 → 1,273,
spacing 2,973 → 706, text box 736 → 0. No other test document changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:24:15 +00:00
Claude 45b852ad4a Layout: in-line drawings move with their run's raise or lowering
An in-line picture, shape or chart always stood on the baseline,
whatever its run's position. Word moves an in-line picture with its
run's position (`w:position`, Font › Advanced › Position):
- lowered, it reaches below the baseline (at most until its top is on
  the baseline) and the line's descent grows to hold it;
- raised, it stands clear of the baseline at its own height.

A drawing's cluster now reaches `obj_h` above the baseline and `obj_d`
below it (negative when raised). It is painted, hit-tested and wrapped
from both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 17:24:15 +00:00
Claude 7eee06a1ab Gap 19: ODT form controls opened and saved as content controls
ODT form controls (ODF 1.3 §13: controls in `office:forms`, each
placed in the text by a `draw:control`) were dropped on open, values
and all. They now open as WordCraft content controls, each with its
value, entries and selection; its name becomes the tag and its label
the title:
- text fields and text areas: plain text controls;
- check boxes: check boxes;
- radio buttons: check boxes with round symbols;
- list boxes: drop-down lists;
- combo boxes: combo boxes;
- date fields: date controls.
Saved as DOCX they become Word content controls.

Content controls of those kinds in the body are written back as form
controls with a `draw:control` anchor; in headers and footers they stay
their text. LibreOffice shows the round-tripped controls with their
values, and the content validates against the ODF 1.3 schema.

Not read: buttons, images, hidden and other controls. Controls' sizes
and looks aren't kept.

Flow model: new `Inline::Control`, kept only by flows that ask for
boxes (ODT); other formats get its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 14:46:01 +00:00
Claude b36e0b9fe9 Gap 19: password-protected ODT opened and saved
A password-protected ODT (ODF 1.3 Part 2 §3.4) failed to open. The
package is now decrypted with the password into a plain one, which the
reader opens as before.
- Supported: AES-256-CBC with a SHA-256 or SHA-1 start key, PBKDF2
  with HMAC-SHA-1, and SHA-256 or SHA-1 1K checksums (LibreOffice's
  encryption).
- Blowfish (the old default) is refused with a message naming it.
- A missing or wrong password gives the same errors as Word files, so
  the app asks for the password.
- Hostile parameters are bounded: iteration count, IV size, ragged
  data, inflated size.

Saving with a password (and saving a document opened with one) writes
the ODT encrypted as LibreOffice does. Every file but the `mimetype`
and manifest is deflated and encrypted, each with its own salt and IV,
and the manifest validates against the ODF manifest schema.
LibreOffice opens WordCraft's encrypted files with the password and
refuses a wrong one; WordCraft opens LibreOffice's.

No new dependencies: the AES, SHA, HMAC, miniz and getrandom crates
the workspace already uses for Word encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 14:24:03 +00:00
Claude 16f0b6ed25 Gap 19: ODT headers and footers keep their drawings on save
Review of the objects commit found two gaps.
- The header and footer reader wasn't given the package's entry list,
  so embedded objects there were still lost on open. It now shares the
  body reader's list and manifest, borrowed rather than copied.
- Headers and footers were flattened for writing without drawings, so
  shapes, text boxes, charts and kept objects in them were dropped on
  save. They now go out as in the body.

The full LibreOffice round-trip harness shows no change on the other
test documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 13:42:25 +00:00
Claude 275037932f Gap 19: ODT embedded spreadsheets and OLE objects kept whole
LibreOffice writes an embedded object's replacement picture in its own
metafile format, which WordCraft can't decode. So an embedded
spreadsheet (`draw:object`, ODF 1.3 §10.4.6) or OLE object
(`draw:object-ole`, §10.4.7) vanished on open, and saving lost it.

Such objects are now kept whole: their files, media type (from the
manifest or the sub-document's body) and replacement picture.
- Display: the replacement picture when WordCraft can show it, else a
  placeholder of the object's size drawn in code (ruled for a
  spreadsheet).
- ODT save: written back as the same object, with its manifest entries
  and original replacement. LibreOffice renders a round-tripped
  spreadsheet pixel-identically.
- Document model: the files live in `passthrough` under `odf-object/`,
  linked from the picture's `ole` by a marker. The DOCX writer skips
  the marker and writes the picture alone.

Flat ODT, a fix to the chart commit: inline sub-documents lacked the
required `office:mimetype`, so the schema rejected flat files holding
charts. Sub-documents now go inline as full `office:document`s (media
type, styles, settings) and are split back into their files on
opening. Replacement pictures and OLE files go inline as base64. A
picture's data is matched to the element that holds it, no longer to
the last `draw:image` before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 13:35:59 +00:00
Claude 135ce2229d Gap 19: ODT charts in drawing groups drawn on open
Review of the chart commit: the engine drew opened charts only at the
paragraph level, so a chart that was a member of a `draw:g` group
opened blank. The pass now goes into groups (bounded depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 12:49:08 +00:00
Claude 3cc5185b1e Gap 19: ODT charts opened and saved as editable charts
A chart object (`draw:object` whose sub-document is an `office:chart`,
ODF 1.3 §10.4.3, Part 3 §11) used to open as its replacement picture.
It is now read into WordCraft's chart model: column, bar (stacked or
not), line, area, pie, doughnut and scatter charts, with categories,
series names and values (gaps included) taken from the chart's own
table through the series' cell range addresses, plus title, legend
position and data labels. The engine draws opened charts in the
document's theme colours, and Chart Design's commands edit them.
Charts WordCraft can't edit as they are (3D, percent-stacked, mixed
plot types, a second value axis, radar and the like) keep their
picture.

WordCraft charts are written as ODF chart objects shaped like
LibreOffice's: a data table, each series and slice in its accent
colour, pies clockwise from the top. They validate against the ODF 1.3
schema, and LibreOffice draws them like WordCraft does. In flat
documents they are written inline as an `office:document`.

Bug fixed: flat ODT reading ended the body at the first nested
`</office:body>`, so an inline sub-document dropped the text after it.
Element lookup now follows nesting, with a bounded depth.

Flow model: new `Inline::Chart`, kept only by flows that ask for
boxes (ODT); other formats still get the alt text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 12:43:51 +00:00
Claude d2b7465b1e Gap 19: ODT leading read as an at-least spacing
LibreOffice's leading (`style:line-spacing`, extra space between lines)
has no WordCraft line spacing mode. It was read as a multiple of a
12 pt line, which spreads the extra space differently from LibreOffice,
so the paragraph started too high. It is now read as at least a 12 pt
line and the leading: the extra space goes above each line, as
LibreOffice draws leading (it also leaves some after the last line,
which isn't followed).

A LibreOffice spacing document went from 6,190 px apart after a round
trip to 2,973, and WordCraft's drawing of it from 8,570 to 5,521.

Regression test: `leading_is_at_least_a_line_and_the_leading`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:41:55 +00:00
Claude 86b1681b54 Gap 19: ODT tracked formatting changes; regions named by xml:id
Tracked formatting changes (`text:format-change` regions, ODF 1.3
§5.5.7, between `text:change-start`/`-end` marks) were dropped. The
text between the marks now gets a formatting revision with the change's
author and date. ODF records no old formatting, so the old formatting
is the text's own: the revision shows and can be accepted, and
rejecting it changes nothing (LibreOffice can't reject one after
reopening either). WordCraft's formatting revisions are written as
format changes around their text.

Fixed: changed regions were written with `text:id` only, but ODF 1.3
requires `xml:id` (which the change marks refer to), so any document
with tracked changes failed schema validation. Both are written now,
and a region named by `xml:id` alone is read.

A LibreOffice document with a tracked formatting change round-trips
pixel for pixel; documents with tracked insertions, deletions and
format changes validate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:36:42 +00:00
Claude fe5d3c92d2 Gap 19: ODT drop caps; tab stops no longer drop paragraph properties
Drop caps (`style:drop-cap`, ODF 1.3 §17.9) are read into WordCraft's
drop cap (lines and distance; one line is none) and written back as
the paragraph style's `style:drop-cap`. WordCraft drops one letter, so
a drop cap of several letters or a word opens as its first letter.

Fixed: the paragraph style key kept its child elements after a
separator, and the background, borders, contextual spacing and
right-to-left direction were added after the tab stops, so a paragraph
with its own tab stops wrote them into the tab stops' place instead of
onto the paragraph properties, and lost them. Attributes and child
elements are now gathered apart
(`tab_stops_and_other_paragraph_properties_together`).

A LibreOffice document with a drop cap round-trips pixel for pixel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 11:29:04 +00:00
Claude 694ef8da96 Gap 19: flat OpenDocument text (.fodt) opened and saved
A flat ODT holds the whole document in one `office:document` element
(ODF 1.3 Part 3 §2.1.2), its pictures inline as base64
`office:binary-data` and its formulas inline in their `draw:object`.
It is opened by taking it apart into a package's files (content,
styles, meta, settings; pictures and formulas as entries, each part's
root keeping the document's namespaces) and reading that package, and
saved by putting a package's files together: one set of automatic
styles, every part's namespaces declared once on the root, pictures and
formulas inline without the link attributes.

`.fodt` opens and saves from Open, Save As, the CLI and the web app.
LibreOffice's own flat documents round-trip as their packages do, and
the written files validate against the ODF 1.3 schema.

The parity doc's ODT fidelity is re-estimated at ~80%, and its status
line, which hadn't been updated since the Asian fonts change (a text
replacement that matched nothing), now lists every change since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 10:05:45 +00:00
Claude 5570236b70 Gap 19: ODT page settings read and written
- Page number format: the page layout's `style:num-format`, and a page
  number field's own (`PAGE \* Arabic`, `roman`, `ROMAN`,
  `alphabetic`, `ALPHABETIC`), both ways.
- Page colour (the first page style's `fo:background-color`).
- Page borders: ODF's sit at the margin with the text inside their
  padding; WordCraft measures a page border from the page edge and the
  text margin from the edge, so the border's space is ODF's margin and
  the margin takes in the border and padding, and back on save.
- Footnote and endnote numbering (`text:notes-configuration`: format,
  first number, counted from 0 in ODF, restart, page foot or document
  end); only what the document sets is written.
- Line numbering (`text:linenumbering-configuration`, on unless it says
  otherwise, ODF 1.3 §19.845: interval, distance, restart on each page).

A LibreOffice document with all of these round-trips pixel for pixel;
WordCraft draws it 7,213 px from LibreOffice (24,025 with the border at
the page edge). The written styles validate against the ODF schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 09:36:50 +00:00
Claude 27160e8821 Gap 19: an ODT footnote no longer shows a footer's text
Headers and footers were put into the document under the ids their
sections named (1, 2…), assuming the body had made no stories of its
own; but notes, comments and text boxes now are stories made first, so
in a document with a footnote and a footer the footer took the note's
id and the note showed the footer's text (a page number field).
Headers and footers now take free ids and the sections are pointed at
them.

Regression test: `a_footnote_keeps_its_text_beside_a_footer`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 09:36:50 +00:00
Claude 5374375301 Gap 19: ODT date formats, sequences, chapters and references
Fields that were read as plain text now open as Word's, so Update Field
recomputes them, and are written back as ODF's:
- dates and times in their date or time style (`number:date-style`,
  `number:time-style`) ↔ `DATE`/`TIME \@ "picture"`: day, month (by
  number or name), year, weekday, hours (12-hour with AM/PM), minutes,
  seconds and the text between, both ways; data styles are written as
  automatic styles and the `number` namespace declared;
- sequences numbering figures and tables (`text:sequence`) ↔ `SEQ` with
  its number format; on save the sequences are declared
  (`text:sequence-decls`); on open a bookmark named by the sequence's
  reference name covers the caption's label and number ("Figure 1"),
  what references to it show;
- chapter fields (`text:chapter`) ↔ `STYLEREF "Heading n"`, by name or
  number (`\n`);
- reference marks as bookmarks, and references to them and to sequences
  (`text:reference-ref`, `text:sequence-ref`) as `REF`/`PAGEREF`.

Captions renumber, references to them and chapter fields refresh after
Update Field (engine test `odt_fields.rs`). A LibreOffice document with
all of them round-trips 13 px apart with the same text; the written
content and styles validate against the ODF 1.3 schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 09:12:52 +00:00
Claude c6bb7a2f57 Fields: a date-time picture's quoted text is shown as it is
Word's date-time pictures (`DATE \@ "…"`) put literal text in single
quotes, two quotes in a row standing for one: `H' o''clock'` shows
"8 o'clock". The formatter read the quotes as text and the letters
inside as instructions (an `a` became AM or PM). Quoted text is now
shown as it is; an unclosed quote runs to the end.

Regression test: `date_pictures_keep_quoted_text`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 09:12:52 +00:00
Claude bb5dc4bcaa Gap 19: ODT bookmarks keep the text they cover
A bookmark over text (`text:bookmark-start`…`text:bookmark-end`, in one
paragraph or across several) was read as a point at its start, and a
Word bookmark over text was written as a point: a reference to it then
updated to nothing. The flow model gets the end of a bookmark
(`Inline::AnchorEnd`, in flows asked for fields, as ODT's is), the
document gets the bookmark's start and end where they are, and the
writer writes a start and an end (a point, `text:bookmark`, when it
ends where it starts). A bookmark started twice, ended twice or ended
before it starts is kept once, as a point when it has no end after it.

References to bookmarks, LibreOffice's references to headings included,
now update to the bookmarked text (engine test `odt_fields.rs`, through
a save and reopen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 08:53:42 +00:00
Claude aea3b4c7d7 Gap 19: ODT tables of contents and indexes read and written
Tables of contents, tables of figures (`text:illustration-index`,
`text:table-index`), alphabetical indexes and bibliographies open as
WordCraft's: Word's `TOC` (levels from the source's
`text:outline-level`; `\c` and the caption name for figures), `INDEX`
and `BIBLIOGRAPHY` fields over their entries as last generated. With a
title the field goes in the title paragraph, as References › Table of
Contents inserts one; without, it spans the entries (new flow inlines
`FieldStart`/`FieldEnd`, mirroring the document's range fields). Update
Table then regenerates them from the headings (engine test
`odt_toc.rs`, titled and untitled, through a save and reopen).

LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles
are WordCraft's TOC 1–9, TOC Heading and Table of Figures, both ways,
also when a file names them without defining them. WordCraft's tables
are written as ODF indexes with their source and entry templates, so
LibreOffice updates them, the entries' links in the Index Link style.
User and object indexes are read as their paragraphs; index marks are
not read yet.

Found on the way, both shown by the table of contents' round trip:
- a style based on LibreOffice's generic Heading style (as its headings
  and index headings are) lost that parent on save, and with it its
  font and spacing;
- a paragraph's tab stops were measured from its own indent only, not
  from an indent its style gives (LibreOffice's `TabsRelativeToIndent`),
  and a named style's stops not from its indent at all.

A LibreOffice document with a table of contents, a table of figures and
an alphabetical index round-trips pixel for pixel (it was 29,717 px
apart and a page short). The parity doc's ODT fidelity is re-estimated
at ~72%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 08:39:20 +00:00
Claude 74da7e0985 Gap 19: split the ODT body reader and writer by topic
A pure move, no behaviour changed: `odt/body.rs` (1,720 lines) and
`odt/writer.rs` (1,716) are split into child modules by topic, the
methods of `Body` and `Writer` moved in `impl` blocks of their own.

- body.rs (930): paragraphs, text, lists, element dispatch;
  body/drawing.rs frames, shapes, groups, formula objects;
  body/notes.rs notes, comments, fields; body/tracked.rs tracked
  changes; body/tables.rs tables.
- writer.rs (618): the writer, paragraphs, inlines, blocks, styles;
  writer/props.rs text and paragraph style properties;
  writer/drawing.rs pictures, text boxes, shapes, groups, formulas;
  writer/tracked.rs tracked changes; writer/notes.rs notes and
  comments; writer/tables.rs tables.

Items the rest of `odt` uses are re-exported where they were. Every
harness document (21) exports content.xml, styles.xml and settings.xml
byte for byte as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 08:01:51 +00:00
Claude 422b59f3bc Gap 19: ODT polygons, polylines, paths and circles read and written
Polygons and polylines (`draw:points`), paths (`svg:d`) and regular
polygons and stars (`draw:corners`, `draw:concave`, `draw:sharpness`)
open as WordCraft freeforms in their view box (none: the box around
their points) instead of being skipped; circles (`draw:circle`, by size
or by centre and radius) open as ellipses.

The path data reader follows SVG path syntax: moves, lines, horizontal
and vertical lines, cubic and quadratic curves and their smooth forms,
and elliptical arcs (converted from endpoint to centre form), absolute
and relative, with repeated commands; curves are followed in 16 straight
steps, a subpath closed by `Z` is a closed path, and input is bounded
(numbers, points, paths, coordinates).

WordCraft's freeforms (ink included) are written as `draw:path` in a
view box of hundredths of a point (whole numbers, as ODF's view box is).

A LibreOffice document with a polygon, a polyline and a closed Bézier
curve (relative `c` and smooth `s` commands) round-trips 333 px apart,
and WordCraft draws it 529 px from LibreOffice; the written content
validates against the ODF 1.3 schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 07:38:35 +00:00
Claude 88a5fff9d5 Gap 19: ODT arrowheads, dashes and gradients read and written
Graphic styles' arrowheads (`draw:marker-start`/`-end`), line dashes
(`draw:stroke-dash`) and gradient fills (`draw:gradient`), defined by
name in the styles, open as WordCraft's arrowheads, dash patterns and
gradients:
- dashes: up to two kinds of dash and one space, in line widths or
  absolute lengths, matched to WordCraft's named dashes when equal;
- gradients: linear and axial along their angle (ODF's 0° runs top to
  bottom, anticlockwise; WordCraft's left to right, clockwise; an angle
  without a unit is tenths of a degree, as ODF 1.2 wrote it), radial,
  ellipsoid, square and rectangular out from their centre (end colour in
  the middle), with border and intensity.

They are written back as definitions in `styles.xml` (WordCraft's own
triangle arrowhead marker; equal definitions share a name; the header
writer's names are prefixed apart). Frames keep gradient fills too.

A line now keeps the direction it is drawn in (`svg:x1`/`y1` is its
start): one drawn leftwards or upwards is flipped that way, so its
arrowheads stay at their ends, and it is written from its start.

A LibreOffice document with arrowheads, dashes and gradients round-trips
215 px apart; WordCraft draws it 518 px from LibreOffice. Exported
styles.xml and content.xml validate against the ODF 1.3 schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 07:15:29 +00:00
Claude f217aafa53 Layout: a sloped line's arrowheads sit at its ends
The renderers draw a line from its frame's top left to its bottom right
(DrawingML's `line`; flips turn the frame), but the arrowhead code and
`end_direction` assumed a line from the bottom left to the top right, so
a sloped line's arrowheads were drawn at the other diagonal's corners,
apart from the line. Horizontal and vertical lines hid it. The
arrowheads now start from the line's own ends, pointing along it.

Regression test: `tests::a_lines_arrowheads_sit_at_its_ends`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 07:15:27 +00:00
Claude 767594b034 Gap 19: ODT groups of shapes and pictures read and written
Groups (`draw:g`) of shapes, text boxes and pictures open as one
WordCraft group instead of being skipped: anchored and wrapped as the
group is, over the box around its members, each member at its place in
it (from its `svg:x`/`svg:y`, its line ends or its `draw:transform`,
turned members turned); groups inside a group are flattened into it.
WordCraft's groups are saved as `draw:g`, members at their offsets with
no anchor of their own, nested groups nested (a canvas as a plain group).

A frame, shape or group whose styles state no `style:wrap` now has text
on both sides, as LibreOffice wraps it (ODF 1.3 §20.400 states no
default), not above and below only; `biggest` is read as the larger
side. A shape whose only paragraph is LibreOffice's empty `<text:p/>`
gets no empty text story.

A LibreOffice document with turned shapes and a group was 7,464 px apart
after a round trip; now 586, with the same text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 06:49:31 +00:00
Claude f9d13f06dd Gap 19: ODT rotated and mirrored shapes and pictures
Shapes and pictures turned by `draw:transform` (rotate, translate and
scale, applied in turn to the object's own coordinates) open turned: the
file's anticlockwise turn about the object's top left corner, followed by
a move, becomes WordCraft's clockwise turn about the centre of the
unrotated box at its offset, as Word places them. They are written back
the same way, in line as well as floating. LibreOffice writes no
`svg:x`/`svg:y` for a turned shape, so before, a turned shape lost both
its turn and its position.

Custom shapes keep their mirroring (`draw:mirror-horizontal`/`-vertical`
on their geometry), and a turned line is written by its turned ends. The
flow keeps the placement of an in-line object that is turned or mirrored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 06:32:22 +00:00
Claude 836bf6553b Gap 19: ODT drawing shapes read and written
Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and
`draw:custom-shape` of a predefined geometry named by its
`draw:enhanced-geometry` `draw:type`) open as WordCraft shapes instead of
being skipped: size, placement (in line or floating, as frames are),
fill and line from the graphic style chain and the default graphic style
(LibreOffice's solid fill and hairline where nothing is stated), their
text, and a line's direction from its two ends. LibreOffice's type names
and its `ooxml-` preset names map onto WordCraft's kinds; a geometry
WordCraft has no shape for opens as a rectangle.

WordCraft's shapes are saved the same way: a custom shape by its type
alone (LibreOffice rebuilds the geometry from it), a line by its ends,
connectors as lines. A shape is saved with `draw:auto-grow-*` off unless
it resizes to fit its text, so LibreOffice keeps its size.

The flow model's text box becomes `Inline::Shape` with a kind; text
boxes are the `TextBox` kind and are written as frames, as before.

Not yet: shapes in groups, polygons, paths, rotation, arrowheads,
dashes, gradients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 06:21:30 +00:00
Claude e15a86bd55 Gap 19: ODT text box frames keep their line, fill, padding and fit
A text box frame's line (colour and width), fill and padding are read
from its graphic style and that style's parents (LibreOffice's Frame
style included) instead of a fixed thin black line on white, and written
back. ODF's defaults (no line, fill or padding) apply where the styles
say nothing. A frame without a height, or with a `fo:min-height` on its
text box, opens as a box that resizes to fit its text and is saved with
a least height again. A LibreOffice text-box document was 1,117 px apart
after a round trip; now 736.

Layout: a floating text box that resizes to fit its text now takes its
text's height (drawn and wrapped around), as inline ones already did;
before, it kept its stored height (an ODT frame without one showed as a
1 pt line with the text spilling over the paragraph).

A formula sub-document's content.xml stays a bare `math:math` root, as
ODF 1.3 Part 3 §2.2.9 B requires (pinned by a test), and the reader now
skips a wrapping `office:math` some writers add instead of taking it for
the MathML root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 03:34:12 +00:00
Claude 1554cfad65 Gap 19: ODT Asian and complex-script fonts and languages
The Asian and complex-script fonts (`style:font-name-asian`/`-complex`,
through their font-face declarations, or `style:font-family-*`), the
complex script's size, weight and posture, and the Asian and complex
languages (`style:language-*`, `style:country-*`) are read on runs and
from the default style, into the run's and the document's
character properties, and written back. Hebrew, Arabic and CJK text
lost its fonts on a save (an RTL test document: 2,043 px apart in
LibreOffice after a round trip, now 529).

A run's own complex-script weight or posture is written once, not also
copied from its Latin bold or italic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 03:10:49 +00:00
Claude c6ff6a602b Gap 19: ODT right-to-left paragraphs read and written
`style:writing-mode` `rl-tb` (or `rl`; ODF 1.3 §20.404) on paragraph
properties opens as a bidirectional paragraph, from automatic and custom
paragraph styles alike, and is written back on the paragraph's or the
custom style's properties. Right-to-left paragraphs opened left to right
and left-aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 03:05:07 +00:00
Claude 2718ca1b6b Gap 19: ODT text boxes keep their text
A `draw:frame` holding a `draw:text-box` (ODF 1.3 §10.4.3) opens as a
WordCraft text box: a text box shape of the frame's size, in line or
floating as the frame is anchored and placed, whose story holds the
box's paragraphs (read as a note's are, with the paragraph and frame
around them put aside meanwhile, so a picture inside the box is a frame
of its own). The text was dropped on open.

Export writes each text box as a frame with a thin border and white fill
(`draw:fill` as well as `fo:background-color`: LibreOffice fills a frame
without `draw:fill` with its default blue). The module documentation now
lists what the reader and writer handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:56:36 +00:00
Claude 9dd0b51f23 Gap 19: ODT unnumbered heading styles are detached from the outline
LibreOffice gives a heading style the outline's numbering unless the
style names no list style (`style:list-style-name=""`, as LibreOffice
writes its own). Our heading styles named none, so a heading whose level
the outline doesn't number still took that level's default indent: a
Word-made document's headings sat indented after a save. Heading styles
of unnumbered levels now name no list style; numbered ones stay
attached. Round-trip of that document: 3,302 px apart, now 1,121.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:45:17 +00:00
Claude 488db37516 Gap 19: ODT contextual spacing and LibreOffice layout settings
Contextual spacing (`style:contextual-spacing`, ODF 1.3 §20.255: no
space between paragraphs of one style) is read from paragraph, custom
and kind styles and written back, and the List Paragraph style is
written like any other, so bulleted items keep their spacing; they were
written in Standard and spread apart.

LibreOffice switches many layout behaviours by `settings.xml`, and a file
without them is laid out its own way. A file's own `settings.xml` is now
kept through a save; a document from elsewhere gets the switches
LibreOffice uses for Word files, as WordCraft lays text out as Word
does. Tab stops are written from the margin either way. A Word-made
document saved as ODT drew 38,652 px apart from LibreOffice's own ODT of
it; now 3,302, with the same line breaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:41:03 +00:00
Claude f719ff2f74 Gap 19: ODT custom character styles kept on open and save
A common text style other than LibreOffice's link, note-mark and
list-label styles (Emphasis, a user's own) opens as a document character
style with its display name, the custom character style it is based on
and its own formatting; the text in it names it, and only what the
spans' automatic styles add is direct formatting. Before, the style was
lost and its formatting copied onto the text. An id a paragraph style
already has gets "Char" added.

Export writes each custom character style the text uses, and those it is
based on, as common text styles; a span's automatic style is based on
its style and holds only the run's direct formatting. LibreOffice keeps
the style when it saves the file. Automatic styles of styles.xml are
never taken for custom styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:27:03 +00:00
Claude 4c83e8a817 Gap 19: ODT list levels keep their indents and number alignment
Each list level's indent and hanging (`fo:margin-left` and
`fo:text-indent` of `style:list-level-label-alignment`) and its label's
alignment (`fo:text-align` of `style:list-level-properties`, ODF 1.3
§17.19-17.20) are read into the document's list levels, for lists and
heading numbering, and written back. Every list was written 36 pt a
level with left-aligned numbers, so LibreOffice's right-aligned numbers
and closer bullets moved on a save (lists test document: 658 px apart
in LibreOffice after a round trip, now 8).

The flow label keeps the position in twentieths of a point, its prefix
and suffix now up to 8 bytes ("Chapter " still fits), so list
paragraphs stay as small as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:14:09 +00:00
Claude 507e7b8050 Gap 19: ODT style chains check custom styles without building them
Each paragraph's style chain was searched for its custom style by
building every custom style in it (cloning its tab stops and the rest)
just to see whether there was one. A cheap check now decides; the style
is built once, by the import that adds it to the document. A test opens
20,000 paragraphs on a 15-deep chain of styles with 60 tab stops each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:07:50 +00:00
Claude cbc19809b6 Gap 19: ODT custom paragraph styles kept on open and save
A common paragraph style other than Standard, the paragraph kinds' and
the note texts' (LibreOffice's Text body, a user's own style) opens as a
document style with its display name, the style it is based on (a
custom parent, a kind's style or Normal) and its own formatting:
spacing, indents, line spacing, alignment, keep with next, borders,
background, tab stops and text formatting. Its paragraphs stay in it;
only what the automatic styles over it add is direct formatting. Before,
the style was lost and its formatting copied onto every paragraph.
Headers, footers, notes and comments are covered too.

Export writes each custom style the paragraphs use, and the custom
styles they are based on, as common styles named by an XML-safe form of
the style id and shown by the style's name; LibreOffice keeps them when
it saves the file. A custom style may not take a name the writer uses
for its own styles (Standard, Footnote, Endnote, the kinds'), so no
style is written twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 02:04:09 +00:00
Claude 3745441899 Gap 19: ODT equations written as formula objects
Each equation is written as an embedded formula object (ODF 1.3
§10.4.3): a frame in line with the text whose `draw:object` points at a
sub-document `Object N/` (declared in the manifest as a formula) whose
content.xml is the equation's MathML, with its linear text as an
annotation. The reader already opens LibreOffice's formula objects, so
equations round-trip. They were written as their linear text.

LibreOffice scales a formula to its frame, so the frame is sized from
the equation's structure (a box per node: fractions, scripts, radicals,
n-ary operators, delimiters, matrices) and centred on the line.
Checked in LibreOffice 24.2 with LibreOffice Math.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:48:43 +00:00
Claude ce57478d15 Gap 19: ODT numbered headings are written as headings, not list items
Export wrapped a heading numbered through its style in a `text:list`
with WordCraft's default list style, so LibreOffice laid its number out
with that list's indents: level 2's "1.1." sat 54 pt in from the margin
(measured against LibreOffice's own file). A heading numbered only
through its style is now a plain `text:h`, numbered by the outline
style. The outline levels also get label alignment followed by a tab,
and the heading styles no longer state zero indents, which in
LibreOffice override the numbering's placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:38:55 +00:00
Claude 21849ab7c0 Gap 19: ODT floating pictures keep their place and wrapping
A frame anchored to a paragraph, character or page (`text:anchor-type`,
ODF 1.3 §19.11) opens as a floating picture: its graphic style's
`style:wrap` and `style:run-through` give the wrap (square on both
sides, left, right or the larger side; top and bottom; behind or in
front of text), `style:horizontal-pos`/`-rel` and `style:vertical-pos`/
`-rel` its alignment or, with `svg:x`/`svg:y`, its offset and what it is
relative to, and `fo:margin-*` its distance from the text. Every
picture used to open in line with the text.

Export writes floating pictures anchored the same way, placed by a
graphic style of their own; pictures in line stay `as-char`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:34:52 +00:00
Claude 3d70bf1bb1 Gap 19: ODT paragraph borders and background read and written
Paragraph borders (`fo:border*`, ODF 1.3 §20.183; each side's
`fo:padding*` as its distance from the text) and background
(`fo:background-color`, `transparent` as none) are read from paragraph
styles, the nearest in the chain but the kind's style and Standard, and
written back on the paragraph's style. Only an empty paragraph with a
bottom border was kept before, as a horizontal rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:20:06 +00:00
Claude 6a30527aa4 Gap 19: ODT heading numbering read and written
`text:outline-style` (ODF 1.3 §16.34) numbers the headings of its
levels. On open its levels (format, prefix, suffix, levels shown, start)
become one list, each level linked to its heading style, and Heading
1-6 number their paragraphs through it, as Word's numbered headings do:
LibreOffice's "1. / 1.1." headings keep their numbers (they were lost).
Levels without a number stay unnumbered.

Export writes a `text:outline-style` from the numbering of the heading
styles; levels without one show no number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:11:31 +00:00
Claude 4a752321d6 Gap 19: ODT font names follow their font-face declarations
A `style:font-name` names a `style:font-face` in office:font-face-decls
(ODF 1.3 §16.23, §20.194); the font is that face's `svg:font-family`.
The reader took the face's name for the font, so LibreOffice's
`FreeSans1` opened as an unknown font "FreeSans1" instead of FreeSans.
Faces declared in content.xml and styles.xml both count; a name with no
face is still taken as the font.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 01:02:53 +00:00
Claude 384ff126c6 Gap 19: ODT table cell borders and padding read and written
Cell borders (`fo:border*`: width, style, colour; ODF 1.3 §20.183) and
padding (`fo:padding*`) are read from table-cell styles. LibreOffice
gives an edge two cells share to one of them only (the left cell's
right side `none`, the right cell's left side drawing it); on open the
line goes to both cells, as WordCraft's cells each draw their own sides.
A table whose cells all say "no line" opens without a grid.

Export writes each cell's padding (its own, its table's or Word's
default) and its borders with each shared edge once, as LibreOffice
does (left and bottom, the first row's top, the last column's right), so
lines no longer double; cells used a fixed 4 pt padding and a grid even
in tables without lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 00:52:00 +00:00
Claude 64248032b2 Gap 19: ODT heading, title, quote and preformatted styles keep their look
On open, the file's own Heading 1-6, Title, Quotations and Preformatted
Text styles (named or displayed so, their whole chain but Standard)
give WordCraft's matching styles their font, size, bold, italic,
colour, spacing, indents, alignment and keep with next: a LibreOffice
heading is bold Liberation Sans, no longer WordCraft's blue heading.
`fo:font-size` percentages are read as a share of the inherited size.

On save, those styles are written from the document's own definitions
in full, instead of fixed ones, so a WordCraft heading looks the same in
LibreOffice. The styles test document now round-trips pixel-identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 00:34:11 +00:00
Claude e533073ceb Gap 19: ODT tab stops read and written
Tab stops (`style:tab-stops`, ODF 1.3 §17.7-17.8) open with their
alignment (left, centre, right, decimal) and leader (dot, hyphen,
underline, middle dot), and are written back on the paragraph's style;
the default tab interval (`style:tab-stop-distance`) round-trips.

LibreOffice measures tabs from the paragraph's indent when its
`TabsRelativeToIndent` setting is on (its default); opening follows the
setting, and saved files carry a settings.xml that turns it off, so
positions run from the margin as in Word and WordCraft.

The flow model carries the tabs in `Para::extra` only for flows that
ask (FlowOpts::paras).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 00:24:11 +00:00
Claude a82d35bbff Gap 19: ODT character formatting read and written
`style:text-properties` beyond bold, italic and colour now open and
save: underline styles (double, thick, dotted, dashes, wave, words only)
and colour, double strike, caps, small caps, hidden text (kept, hidden),
letter spacing, scale, text raised or lowered at full size (a position,
not superscript), outline, shadow, emboss, engrave and language.

A monospaced font keeps its own name; it was read as "code" and written
as Courier New. The default style's pair kerning (`style:letter-kerning`)
is read into the document defaults and written back, so lines break
where LibreOffice breaks them (round-trip renders of the test documents
are now pixel-identical in LibreOffice).

The flow model carries this in `Fmt::extra` only for flows that ask
(FlowOpts::chars); the other formats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 00:16:38 +00:00
Claude e4bac37236 Gap 19: ODT lists keep their number formats, bullets and identity
Each level of a list style (ODF 1.3 §16.30-16.33) is read with its
number format (1, i, I, a, A, none), prefix and suffix, levels shown,
start value and bullet character, and written back. Each `text:list` is
a list of its own unless it continues the one before it
(`text:continue-numbering`) or the one it names (`text:continue-list`);
an item's `text:start-value` restarts from there. A list inside a note
or table cell leaves the list around it alone.

Before, consecutive lists merged into one: three LibreOffice lists
numbered 1., i. and A. opened as one decimal list 1-7.

The flow model's ListInfo carries a list number and a compact label
(prefix and suffix up to 12 bytes, so "Chapter %1" fits) only for flows
that ask for them (FlowOpts::lists); the other formats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-01 00:08:10 +00:00
Claude bc50c6f7f9 Gap 19: ODT export keeps replies to replies
Export wrote only the direct replies of a comment, so a reply to a reply
was lost on save. Every reply in a comment's thread is written now, each
answering the comment that starts the thread, as LibreOffice's threads
are flat. A reply chain that loops (hostile) is written once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 23:53:03 +00:00
Claude 084d26a3c8 Gap 19: ODT comments read and written
`office:annotation` (ODF 1.3 §14.1) opens as a document comment with its
author, date, initials and text; `office:name` and
`office:annotation-end` (§14.2) give its range, across paragraphs too.
LibreOffice's resolved state (`loext:resolved`) and reply threads
(`loext:parent-name`) are kept: a reply is a comment of its parent with
no range of its own. An end with no comment is dropped, and a comment
whose end never comes covers nothing. LibreOffice's all-zero date is no
date. Before, comments were dropped on open.

Export writes each comment named, with its end where its range ends,
and its replies after it naming it; LibreOffice keeps the threads when
it saves the file again. Truncated XML no longer loses the paragraph a
note or comment sits in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 23:48:09 +00:00
Claude 1dbcf073a4 Gap 19: ODT footnotes and endnotes read and written
`text:note` (ODF 1.3 §6.3) opens as a note story built as Word's: its
paragraphs in Footnote Text or Endnote Text, the first starting with the
note's own mark, the reference in the text in the reference style, and a
`text:label` custom mark kept. The paragraph a note sits in (with its
spans, list and table cell) is put aside while the note's content is
read, and goes on after it. Notes inside notes, in deleted text or in
frames are left out. Before, notes were dropped on open.

Export writes each note with a citation numbered as LibreOffice shows
it (footnotes 1, 2, 3; endnotes i, ii, iii) or its custom mark, its
paragraphs in Footnote or Endnote styles defined in styles.xml. An
insertion open around a note ends before it and goes on after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 23:31:02 +00:00
Claude 4959ac5f63 Gap 19: an ODT header's spacing is inside its height, as LibreOffice draws it
A LibreOffice header 2 cm high with 1 cm spacing puts the text 2 cm below
the page margin: `fo:min-height` takes in the `fo:margin-bottom` spacing.
Import added the spacing on top, so the text started a spacing too low
(measured: 113.9 pt in LibreOffice, 142.3 pt after a WordCraft save). The
spacing now counts only past the height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 23:17:19 +00:00
Claude 3402e9b644 Gap 19: ODT headers and footers written; text fields read and written
Export writes each section's headers and footers into its master page
(style:header, -left for even pages, -first for a different first page;
an empty element where Word shows nothing), with their automatic styles
in styles.xml under names of their own. Word's page-edge header and
footer distances become ODF's page margin plus header/footer band
height, and import converts back, so headers sit where LibreOffice
draws them.

Text fields (ODF 1.3 §7: page number and count, date, time, document
information, bookmark references) open as WordCraft fields with their
shown text as the result, and are written back as ODF fields. Fixed
dates and fields without a Word equivalent stay text; Word fields ODF
lacks are written as their result. The flow model carries fields only
when asked (FlowOpts), so the other formats are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 23:13:26 +00:00
Claude 9436271273 Gap 19: ODT paragraph spacing, indents and line spacing
Read `fo:margin-*`, `fo:text-indent`, `fo:line-height` (percent, length,
normal), `style:line-height-at-least` and `style:line-spacing` (leading,
as a proportional approximation) from automatic and common paragraph
styles (ODF 1.3 §20.204-209, §20.225, §20.317-318), and write them back
from each paragraph's direct formatting.

The default paragraph style and Standard give the document defaults, on
ODF's initial values: an ODT paragraph without spacing has none, not
Word's 8 pt after and 1.15 lines. Export writes the document's Normal
spacing as the default paragraph style, so the copy reopens the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-10-31 22:58:55 +00:00
393 changed files with 39876 additions and 3436 deletions
+1
View File
@@ -46,3 +46,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+11 -2
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -2960,7 +2960,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6941,18 +6941,26 @@ dependencies = [
name = "wordcraft-formats"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
"cfb",
"chardetng",
"chrono",
"encoding_rs",
"getrandom 0.3.4",
"hmac",
"image",
"js-sys",
"log",
"miniz_oxide 0.9.1",
"proptest",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.1",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zeroize",
"zip 3.0.0",
]
@@ -7043,6 +7051,7 @@ dependencies = [
name = "wordcraft-ui-egui"
version = "0.15.1"
dependencies = [
"accesskit_consumer",
"cfb",
"chrono",
"craft-i18n",
+4 -2
View File
@@ -67,7 +67,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -92,6 +92,8 @@ zeroize = "1"
# dependency of other crates.
libc = "0.2"
# time
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
@@ -132,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+80 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
@@ -655,6 +655,8 @@ fn mime_for(name: &str) -> &'static str {
Some("pdf") => "application/pdf",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("odt") => "application/vnd.oasis.opendocument.text",
Some("ott") => "application/vnd.oasis.opendocument.text-template",
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
Some("rtf") => "application/rtf",
Some("html") => "text/html",
Some("md" | "txt") => "text/plain",
+2
View File
@@ -239,6 +239,8 @@ fn format_name(ext: &str) -> String {
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"ott" => "OpenDocument Text Template".into(),
"fodt" => "Flat OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
+39 -2
View File
@@ -27,7 +27,7 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
@@ -54,7 +54,13 @@ impl eframe::App for App {
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
let mut window = self.4;
// Only until the off-screen check below has run: it is the one reader.
if self.1.is_some() {
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
window.on_a_monitor = on_a_monitor;
window.primary_monitor_rect = primary_monitor_rect;
}
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
@@ -126,6 +132,37 @@ impl eframe::App for App {
}
}
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
/// report the window or primary monitor (Wayland, headless).
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
let Some(w) = frame.winit_window() else {
return (None, None);
};
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return (None, None);
}
let Some(pos) = w.outer_position().ok() else {
return (None, None);
};
let size = w.outer_size();
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
};
let monitors: Vec<egui::Rect> =
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
let primary_monitor_rect = w.primary_monitor().map(|m| {
let pos = m.position();
let size = m.size();
egui::Rect::from_min_size(
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
)
});
(on_a_monitor, primary_monitor_rect)
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
+6 -1
View File
@@ -35,7 +35,9 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -187,3 +189,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
+1
View File
@@ -1,5 +1,6 @@
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::path::{Path, PathBuf};
+3 -2
View File
@@ -10,8 +10,9 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
test-support = []
[dependencies]
+6 -1
View File
@@ -213,6 +213,12 @@ pub struct ShapeExtra {
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
/// it). Dropped when the shape is made another kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf_geometry: Option<std::sync::Arc<String>>,
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -384,7 +390,6 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
+12
View File
@@ -254,6 +254,18 @@ pub struct ContentControl {
/// `w:sdtEndPr`, kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub end_pr_xml: String,
/// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and
/// `svg:height`), points: kept for saving back as one.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_size: Option<[f32; 2]>,
/// How far that form control stands raised from the baseline (points, negative lowered:
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_raise: Option<f32>,
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
#[serde(skip_serializing_if = "String::is_empty")]
pub form_xml: String,
}
impl ContentControl {
+52 -3
View File
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
@@ -62,8 +62,8 @@ impl Document {
body: vec![para_block(Paragraph::new())],
last_section: SectionProps::default(),
parts: BTreeMap::new(),
styles: StyleSheet::builtin(),
numbering: Numbering::default(),
styles: Arc::new(StyleSheet::builtin()),
numbering: Arc::default(),
comments: BTreeMap::new(),
revisions: Vec::new(),
settings: Settings::default(),
@@ -76,6 +76,7 @@ impl Document {
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
}
}
@@ -152,6 +153,7 @@ impl Document {
passthrough,
glossary,
media_index: _,
kept: _,
} = self;
let Document {
body: other_body,
@@ -171,6 +173,7 @@ impl Document {
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
kept: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
@@ -734,6 +737,52 @@ impl Document {
}
}
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
/// drawing from its data), which doesn't change the document: the markup kept as read for
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
// The stories (text boxes', notes') as read before, when filling changes one: they stay
// as read after it, so the paragraphs showing them keep their markup.
let holds = |b: &Arc<Block>| {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
any
};
let as_read: Vec<u32> = match self.kept.get() {
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
}
_ => Vec::new(),
};
let mut moves: Vec<crate::kept::Moved> = Vec::new();
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
if !any {
continue;
}
let before = crate::kept::identities(b);
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
if p.objects.iter().any(wants) {
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
p.touch();
}
});
moves.push((before, crate::kept::identities(b)));
}
}
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
if let Some(k) = self.kept.get_mut() {
for (before, after) in moves {
k.follow(&before, &after);
}
k.story_fps.extend(refreshed);
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
+4 -4
View File
@@ -1054,7 +1054,7 @@ impl Document {
let mut default_style = None;
if keep_styles {
if src.styles.get("Normal").is_none() {
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
}
// Ids given out here are taken too: two styles never get the same new one.
let mut given = std::collections::HashSet::new();
@@ -1089,7 +1089,7 @@ impl Document {
abs_map.insert(a.id, id);
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
self.numbering.abstracts.push(a);
Arc::make_mut(&mut self.numbering).abstracts.push(a);
}
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
let mut nums = BTreeMap::new();
@@ -1099,7 +1099,7 @@ impl Document {
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
self.numbering.nums.push(n);
Arc::make_mut(&mut self.numbering).nums.push(n);
}
let renumber = |n: &mut Option<crate::props::NumRef>| {
if let Some(n) = n.as_mut()
@@ -1138,7 +1138,7 @@ impl Document {
}
if self.styles.get(&st.id).is_none() {
renumber(&mut st.para.numbering);
self.styles.upsert(st);
Arc::make_mut(&mut self.styles).upsert(st);
}
}
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
+21 -19
View File
@@ -663,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
// The deepest nesting allowed, each table in the first cell of the one around it, ahead of
// that cell's paragraph: the innermost paragraphs come first in the document but the
// paragraph walk stops short.
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 0..20 {
for _ in 1..MAX_TABLE_DEPTH {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
@@ -690,19 +692,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
let mut ours = doc(&["ours"]);
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
let mut theirs = doc(&["theirs"]);
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
theirs.styles.upsert(style);
Arc::make_mut(&mut theirs.styles).upsert(style);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
p.runs[0].props.del = Some(0);
@@ -746,11 +748,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
use crate::styles::{Style, StyleKind, StyleSheet};
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
theirs.styles = StyleSheet::empty();
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
theirs.styles = Arc::new(StyleSheet::empty());
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
let big = CharProps { size: Some(20.0), ..Default::default() };
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Big".into());
p.runs[0].props.style = Some("Link".into());
@@ -764,15 +766,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
use crate::styles::Style;
let mut ours = doc(&["ours"]);
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let mut theirs = doc(&["theirs"]);
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
a.levels[0].style = Some("Heading1".into());
let level = a.levels[0].clone();
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
let before = ours.numbering.abstracts.len();
ours.adopt_with(theirs, true);
@@ -812,10 +814,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
use crate::{Revision, RevisionKind};
for keep in [false, true] {
let mut ours = doc(&["ours"]);
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
let mut theirs = doc(&["theirs"]);
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
// Live and old props name the same styles (ours has them too) and their list.
@@ -843,8 +845,8 @@ fn adopted_styles_never_become_our_defaults() {
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
// A localized default character style, and one whose id we have too.
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
ours.adopt_with(theirs, keep);
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
}
+11
View File
@@ -51,6 +51,17 @@ pub struct Embedded {
pub refs: Vec<EmbeddedRef>,
}
impl Embedded {
/// The root element of an ODF embedded object's markup: its files are kept in
/// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture.
pub const ODF_OBJECT: &'static str = "<wordcraft:odf-object";
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
pub fn is_odf_object(&self) -> bool {
self.xml.trim_start().starts_with(Self::ODF_OBJECT)
}
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
+780
View File
@@ -0,0 +1,780 @@
//! Markup kept from the file a document was opened from, so that what isn't edited is written
//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the
//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the
//! model has no place for (an unknown element, an attribute nobody reads) survives a save that
//! way. Never saved with the document (WordCraft's own format), and not part of its content
//! (`==` ignores it).
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use crate::Paragraph;
/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without
/// bound: past it, paragraphs are written from the model as before).
pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024;
/// The kept markup of a document (see the module docs).
#[derive(Clone, Debug, Default)]
pub struct Kept(Option<Arc<KeptXml>>);
/// What a reader kept.
#[derive(Clone, Debug, Default)]
pub struct KeptXml {
/// The format it came from (`"odt"`): only that format's writer uses it.
pub format: String,
/// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical
/// paragraphs keep the first's).
pub paras: HashMap<u64, Arc<str>>,
/// The same for the body's tables ([`table_fingerprint`]).
pub tables: HashMap<u64, Arc<str>>,
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
pub pending_tables: Vec<(usize, Arc<str>)>,
/// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the
/// start tags opening before it and how many close after it. Only paragraphs that were the
/// only one of their kind have them (a copy can't open a section twice).
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and
/// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once).
pub twin_wraps: HashMap<(u64, u64), (Vec<Arc<str>>, u32)>,
/// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are
/// the only one of their kind).
pub table_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: those of the body's `n`th table, until `seal_kept`.
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, u32))>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
pub groups: HashMap<u64, (u32, u32)>,
/// While reading: the same by paragraph `rev`, until `seal_kept`.
pub pending_groups: HashMap<u64, (u32, u32)>,
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
/// showing one is written as read only while it is as read.
pub story_fps: HashMap<u32, u64>,
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
/// paragraph holding comments is written as read only while they are as read.
pub comment_fps: HashMap<u32, u64>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
/// them into fingerprints.
pub pending: HashMap<u64, Arc<str>>,
/// Bytes kept so far.
pub bytes: usize,
/// The keys of [`KeptXml::defs`] in the order the file had them.
pub order: Vec<String>,
/// Files of the package kept definitions refer to (a style's fill picture), by path.
pub files: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's style definitions' [`styles_fingerprint`] when read: while it is the
/// same, the file's own style definitions can be written as they were.
pub styles_fp: Option<u64>,
/// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the
/// file's own page layouts and master pages can be written as they were.
pub pages_fp: Option<u64>,
/// Each section's master page, as the file named it where its section starts (`None`: the
/// file's default), for a writer naming the file's own master pages.
pub section_masters: Vec<Option<String>>,
}
impl PartialEq for Kept {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Kept {
pub fn new(x: KeptXml) -> Kept {
Kept(Some(Arc::new(x)))
}
pub fn get(&self) -> Option<&KeptXml> {
self.0.as_deref()
}
/// What `format`'s writer may use, if anything was kept from such a file.
pub fn of(&self, format: &str) -> Option<&KeptXml> {
self.get().filter(|k| k.format == format)
}
pub fn get_mut(&mut self) -> Option<&mut KeptXml> {
self.0.as_mut().map(Arc::make_mut)
}
pub fn clear(&mut self) {
self.0 = None;
}
/// After reading: each paragraph still marked pending (by its `rev`, which any change after
/// the reader made it would have moved on) gets its markup under its fingerprint.
pub fn seal(&mut self, paras: &mut dyn Iterator<Item = &Paragraph>) {
let Some(k) = self.get_mut() else { return };
if k.pending.is_empty() {
return;
}
for p in paras {
if let Some(xml) = k.pending.remove(&p.rev) {
k.paras.entry(fingerprint(p)).or_insert(xml);
}
}
k.pending.clear();
}
}
impl KeptXml {
/// The markup kept for paragraph `p`, while it still is what was read.
pub fn para(&self, p: &Paragraph) -> Option<&Arc<str>> {
if self.paras.is_empty() {
return None;
}
self.paras.get(&fingerprint(p))
}
/// The markup kept for table `t`, while it still is what was read.
pub fn table(&self, t: &crate::Table) -> Option<&Arc<str>> {
if self.tables.is_empty() {
return None;
}
self.tables.get(&table_fingerprint(t))
}
/// Whether the comments paragraph `p` holds (those it begins, with their replies) are as
/// read and are all the comments its kept markup `xml` holds: a reply written elsewhere
/// in the file, or one added since, isn't in it.
pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool {
let in_markup = xml.matches("<office:annotation ").count() + xml.matches("<office:annotation>").count();
let mut count = 0usize;
for o in &p.objects {
if let crate::para::InlineObject::CommentStart { id } = o {
let (fp, n) = comments_fingerprint(doc, *id);
if self.comment_fps.get(id) != Some(&fp) {
return false;
}
count = count.saturating_add(n);
}
}
count == in_markup
}
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
/// as read.
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
// A note's citation is its number in the document's numbering, which is the page setup's.
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
if notes && !self.pages_as_read(doc) {
return false;
}
p.objects
.iter()
.flat_map(|o| match o {
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
o => o.text_boxes(),
})
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
}
/// The wrappers kept around table `t`, while it still is what was read.
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
if self.table_wraps.is_empty() {
return None;
}
self.table_wraps.get(&table_fingerprint(t))
}
/// The wrappers kept around paragraph `p`, while it still is what was read.
pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec<Arc<str>>, u32)> {
if self.wraps.is_empty() && self.twin_wraps.is_empty() {
return None;
}
let fp = fingerprint(p);
self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev)))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
if self.pending_groups.len() < 100_000 {
self.pending_groups.insert(rev, group);
}
}
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
if self.groups.is_empty() {
return None;
}
self.groups.get(&fingerprint(p)).copied()
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES {
self.bytes += n;
self.pending_wraps.insert(rev, wrap);
}
}
/// Keep wrapper markup for the body's `n`th table, within the budget.
pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec<Arc<str>>, u32)) {
let size: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES {
self.bytes += size;
self.pending_table_wraps.push((n, wrap));
}
}
/// Keep `xml` for the body's `n`th table, within the budget.
pub fn keep_pending_table(&mut self, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_tables.push((n, xml));
}
}
/// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending(&mut self, rev: u64, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending.insert(rev, xml);
}
}
/// Keep a named definition, within the budget.
pub fn keep_def(&mut self, key: String, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) {
self.bytes += xml.len();
self.order.push(key.clone());
self.defs.insert(key, xml);
}
}
/// Keep a file kept definitions refer to, within the budget.
pub fn keep_file(&mut self, path: String, bytes: Vec<u8>) {
if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) {
self.bytes += bytes.len();
self.files.insert(path, Arc::new(bytes));
}
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
let Some(fp) = self.pages_fp else { return false };
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
fp == now
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc))
}
}
/// What `doc`'s style definitions are: its styles, lists and default tab interval.
pub fn styles_fingerprint(doc: &crate::Document) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and
/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`].
pub struct Identities {
paras: Vec<(u64, u64)>,
table: Option<u64>,
}
/// A block's [`Identities`] before and after a change that keeps what it is.
pub type Moved = (Identities, Identities);
/// The [`Identities`] of block `b`.
pub fn identities(b: &crate::Block) -> Identities {
let mut paras_of = Vec::new();
match b {
crate::Block::Para(p) => paras_of.push(p),
crate::Block::Table(t) => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, 1, &mut paras_of);
}
}
}
}
Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) }
}
impl KeptXml {
/// The markup kept for a block's paragraphs and table follows them from `before` to `after`
/// (what they were and are: see [`crate::Document::fill_objects`]).
pub fn follow(&mut self, before: &Identities, after: &Identities) {
for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) {
if fp == nfp && rev == nrev {
continue;
}
if let Some(x) = self.paras.remove(fp) {
self.paras.entry(*nfp).or_insert(x);
}
if let Some(w) = self.wraps.remove(fp) {
self.wraps.entry(*nfp).or_insert(w);
}
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
}
// Its group too: all a group's paragraphs are written as read, or none.
if let Some(g) = self.groups.remove(fp) {
self.groups.entry(*nfp).or_insert(g);
}
}
if let (Some(t), Some(nt)) = (before.table, after.table)
&& t != nt
{
if let Some(x) = self.tables.remove(&t) {
self.tables.entry(nt).or_insert(x);
}
if let Some(w) = self.table_wraps.remove(&t) {
self.table_wraps.entry(nt).or_insert(w);
}
}
}
}
/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes).
const MAX_STORY_DEPTH: usize = 8;
/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes
/// they hold in turn.
pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut todo = vec![(id, 0usize)];
let mut seen = std::collections::HashSet::new();
while let Some((id, depth)) = todo.pop() {
if !seen.insert(id) {
continue;
}
let blocks = doc.parts.get(&id).map(|p| &p.blocks);
match serde_json::to_vec(&(id, blocks)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
if depth >= MAX_STORY_DEPTH {
continue;
}
let Some(blocks) = blocks else { continue };
let mut held = Vec::new();
paras(blocks, 0, &mut held);
for p in held {
for o in &p.objects {
todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1)));
if let crate::para::InlineObject::NoteRef { id, .. } = o {
todo.push((*id, depth + 1));
}
}
}
}
h.finish()
}
/// Most replies a comment's fingerprint follows (hostile input).
const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &scoped_replies(doc))
}
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
struct Scope {
/// The document's address: it can't move or change while borrowed for the writing.
doc: usize,
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
pages: Option<u64>,
}
thread_local! {
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
/// How many times [`replies`] was built (tests).
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = prev);
}
}
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
let key = std::ptr::from_ref(doc) as usize;
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
let _restore = Restore(prev);
f()
}
/// The scope for `doc`, if it is being written: `f` of it.
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
let key = std::ptr::from_ref(doc) as usize;
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
}
/// [`replies`], built once while `doc` is being written.
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
#[cfg(test)]
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
out.entry(p).or_default().push(*id);
}
}
out
}
/// [`comments_fingerprint`], with each comment's `replies`.
fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, Vec<u32>>) -> (u64, usize) {
let mut ids = vec![root];
let mut seen = std::collections::HashSet::from([root]);
let mut i = 0;
while let Some(&id) = ids.get(i) {
for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) {
// (A reply naming itself, or a circle of them, is followed once.)
if ids.len() < MAX_REPLIES && seen.insert(*r) {
ids.push(*r);
}
}
i += 1;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for id in &ids {
let c = doc.comments.get(id);
let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks);
match serde_json::to_vec(&(c, text)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
}
(h.finish(), ids.len())
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
let parts: Vec<(&u32, &crate::Part)> =
doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect();
let s = &doc.settings;
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(
sections,
parts,
&s.page_color,
&s.page_picture,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(t) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`):
/// equal for a paragraph nobody changed.
pub fn fingerprint(p: &Paragraph) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(p) {
Ok(v) => v.hash(&mut h),
// Unserializable (no paragraph is): a value no paragraph's markup is kept under.
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// Deepest table nesting [`crate::Document::seal_kept`] looks into.
const MAX_SEAL_DEPTH: usize = 16;
fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) {
for b in bl {
match &**b {
crate::Block::Para(p) => out.push(p),
crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, depth + 1, out);
}
}
}
crate::Block::Table(_) => {}
}
}
}
impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
if let Some(t) = tables.get(n) {
k.tables.entry(table_fingerprint(t)).or_insert(xml);
}
}
// Wrappers, for tables that are the only one of their kind (a copy can't open a
// section twice).
let pending = std::mem::take(&mut k.pending_table_wraps);
if !pending.is_empty() {
let fps: Vec<u64> = tables.iter().map(|t| table_fingerprint(t)).collect();
for (n, w) in pending {
if let Some(fp) = fps.get(n)
&& fps.iter().filter(|f| *f == fp).count() == 1
{
k.table_wraps.insert(*fp, w);
}
}
}
}
// The text boxes' and shapes' stories as read, for paragraphs showing them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
let fps: HashMap<u32, u64> = self
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
.collect();
if let Some(k) = self.kept.get_mut() {
k.story_fps = fps;
}
}
// The comments as read, for paragraphs holding them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() {
let replies = replies(self);
let fps: HashMap<u32, u64> =
self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect();
if let Some(k) = self.kept.get_mut() {
k.comment_fps = fps;
}
}
if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) {
return;
}
let mut all = Vec::new();
paras(&self.body, 0, &mut all);
for p in self.parts.values() {
paras(&p.blocks, 0, &mut all);
}
let mut kept = std::mem::take(&mut self.kept);
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
// tell apart) isn't kept.
if let Some(k) = kept.get_mut()
&& !k.pending_groups.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
let pending = std::mem::take(&mut k.pending_groups);
let mut broken = std::collections::HashSet::new();
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
for (rev, fp) in &fps {
if let Some(g) = pending.get(rev) {
if count.get(fp) != Some(&1) {
broken.insert(g.0);
}
members.push((*rev, *fp, *g));
}
}
for (rev, fp, g) in members {
if broken.contains(&g.0) {
k.pending.remove(&rev);
} else {
k.groups.insert(fp, g);
}
}
}
// Wrappers, for paragraphs that are the only one of their kind.
if let Some(k) = kept.get_mut()
&& !k.pending_wraps.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
for (rev, fp) in fps {
if let Some(w) = k.pending_wraps.remove(&rev) {
if count.get(&fp) == Some(&1) {
k.wraps.insert(fp, w);
} else {
k.twin_wraps.insert((fp, rev), w);
}
}
}
k.pending_wraps.clear();
}
kept.seal(&mut all.into_iter());
self.kept = kept;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_paragraph_keeps_its_markup_until_it_changes() {
let mut p = Paragraph::with_text("Hello", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hello</text:p>"));
}
kept.seal(&mut std::iter::once(&p));
let k = kept.of("odt").unwrap();
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
assert!(k.pending.is_empty());
// The same paragraph again (a copy, its rev moved on): still what was read.
p.touch();
assert!(k.para(&p).is_some());
// Changed: written from the model.
p.text.push('!');
assert!(k.para(&p).is_none());
assert!(kept.of("docx").is_none());
}
#[test]
fn a_paragraph_changed_while_reading_keeps_nothing() {
let mut p = Paragraph::with_text("Hi", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hi</text:p>"));
}
p.touch();
kept.seal(&mut std::iter::once(&p));
assert!(kept.of("odt").unwrap().paras.is_empty());
}
#[test]
fn kept_markup_is_bounded() {
let mut k = KeptXml::default();
let big: Arc<str> = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1));
k.keep_pending(1, big.clone());
k.keep_pending(2, big);
assert_eq!(k.pending.len(), 1);
}
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
#[test]
fn filling_objects_keeps_groups_and_stories_as_read() {
use crate::para::InlineObject;
let field =
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
let mut doc = crate::Document::from_text("Body");
let mut boxed = Paragraph::with_text("In the box", Default::default());
boxed.insert_object(0, field(), &Default::default()).unwrap();
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
let fp = fingerprint(doc.body[0].as_para().unwrap());
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
k.groups.insert(fp, (1, 1));
k.story_fps.insert(id, story_fingerprint(&doc, id));
doc.kept = Kept::new(k);
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
if let InlineObject::Field { result, .. } = o {
*result = "filled".into();
}
});
let p = doc.body[0].as_para().unwrap();
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
let k = doc.kept.get().unwrap();
assert!(k.para(p).is_some());
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
/// Writing a document checks each kept paragraph's comments against what was read: the
/// comments' replies are looked up once for the whole document, not once per comment.
#[test]
fn writing_many_comments_looks_their_replies_up_once() {
use crate::para::InlineObject;
let n = 50u32;
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
let mut doc = crate::Document::from_text(&text);
for i in 0..n {
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
.unwrap()
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
.unwrap();
}
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
for i in 0..n {
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
}
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
let before = REPLY_BUILDS.with(std::cell::Cell::get);
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
assert!(all, "every paragraph's comment is as read");
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
// Outside a writing, nothing is kept between calls.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
}
}
+17 -6
View File
@@ -2,9 +2,9 @@
//!
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
//! touch (`Arc::make_mut`).
//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions
//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the
//! renderer) and edits copy only what they touch (`Arc::make_mut`).
//!
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
//! compare in document order.
@@ -28,6 +28,7 @@ pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod kept;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -39,6 +40,7 @@ pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod para;
pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
@@ -47,6 +49,7 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
@@ -84,7 +87,7 @@ pub use error::{DocError, Result};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
@@ -98,8 +101,12 @@ pub struct Document {
/// Properties of the last section (OOXML's body-level `w:sectPr`).
pub last_section: SectionProps,
pub parts: BTreeMap<u32, Part>,
pub styles: StyleSheet,
pub numbering: Numbering,
/// Shared, like the blocks: an undo step copies it only when a command changes it (a full
/// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes
/// a step).
pub styles: Arc<StyleSheet>,
/// Shared like [`Document::styles`].
pub numbering: Arc<Numbering>,
pub comments: BTreeMap<u32, Comment>,
pub revisions: Vec<Revision>,
pub settings: Settings,
@@ -127,6 +134,10 @@ pub struct Document {
/// by `==`).
#[serde(skip)]
media_index: MediaIndex,
/// Markup kept from the file it was opened from, for writing back what isn't edited (see
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
}
#[cfg(test)]
+114 -2
View File
@@ -1,5 +1,6 @@
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
//! exported HTML/Markdown file where a browser would run them.
//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the
//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser
//! would run them.
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
@@ -39,10 +40,121 @@ fn scheme(url: &str) -> Option<String> {
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
}
/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`)
/// or `#bookmark`. None for any other field, or one with no target.
pub fn hyperlink_target(instr: &str) -> Option<String> {
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let args = field_args(instr);
let mut args = args.iter().peekable();
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\');
let (mut url, mut anchor) = (None, None);
while let Some(arg @ (a, _)) = args.next() {
if !is_switch(&arg) {
url = url.or(Some(a.as_str()));
continue;
}
// `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none.
let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@");
let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten();
if a.eq_ignore_ascii_case("\\l")
&& let Some((v, _)) = value
{
anchor = Some(v.as_str());
}
}
let url = url.map(str::trim).filter(|u| !u.is_empty());
let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty());
match (url, anchor) {
// `\l` replaces a fragment the address already has, as in Word.
(Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))),
(Some(u), None) => Some(u.to_string()),
(None, Some(a)) => Some(format!("#{a}")),
(None, None) => None,
}
}
/// A field code's words: the field's name, its switches and its arguments, each with whether
/// it was quoted. In quotes a backslash escapes the next character, as in Word.
fn field_args(instr: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
let mut word = String::new();
if c == '"' {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' => word.extend(chars.next()),
_ => word.push(c),
}
}
out.push((word, true));
} else {
word.push(c);
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
word.push(c);
}
out.push((word, false));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling.
#[test]
fn hyperlink_field_codes_name_their_targets() {
for (code, want) in [
(r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")),
(r#"HYPERLINK \l "heading""#, Some("#heading")),
(r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")),
(r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")),
("HYPERLINK https://example.org/b", Some("https://example.org/b")),
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
// Switches that take a value, before or after the address, and those that take none.
(r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")),
(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")),
(r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")),
(r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")),
(r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")),
(r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")),
(r#"HYPERLINK \l "bm" \l"#, Some("#bm")),
// A bookmark's own `#`, an address that has a fragment, and an empty address.
(r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")),
(r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")),
(r#"HYPERLINK "" \l "x""#, Some("#x")),
// No target, or not a `HYPERLINK` field.
("HYPERLINK", None),
(r#"HYPERLINK "" \o "tip""#, None),
(r#"HYPERLINKS "https://example.org/""#, None),
(r#"HYPERLINKX "http://example.com""#, None),
(r#"HYPERLINK\l "x""#, None),
("PAGEREF _Toc1 \\h", None),
("ab日本語", None),
("", None),
] {
assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}");
}
}
#[test]
fn only_web_and_mail_links_are_followable() {
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
+3
View File
@@ -19,6 +19,9 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
fx: crate::picfx::PicFx,
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
+190
View File
@@ -0,0 +1,190 @@
//! Picture colour corrections read from a file: brightness, contrast and transparency
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
//!
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
//! the picture as it is.
//!
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
//! only at the end:
//!
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
//!
//! (as if half the brightness shift were applied before the contrast and half after). Measured
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
//!
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
use serde::{Deserialize, Serialize};
/// The largest magnitude of a correction (100 %).
pub const FX_MAX: i32 = 100_000;
/// Brightness, contrast and transparency of a picture (see the module docs).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct PicFx {
/// −100 000 (black) ..= 100 000 (white).
pub bright: i32,
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
pub contrast: i32,
/// 0 (opaque) ..= 100 000 (invisible).
pub transparency: i32,
}
impl PicFx {
/// Corrections clamped to their ranges.
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
}
/// The same corrections, each clamped to its range (for values that came from anywhere).
pub fn clamped(self) -> PicFx {
PicFx::new(self.bright, self.contrast, self.transparency)
}
/// No correction at all.
pub fn is_identity(&self) -> bool {
*self == PicFx::default()
}
/// Brightness or contrast changes the pixels (transparency alone doesn't).
pub fn changes_pixels(&self) -> bool {
let f = self.clamped();
f.bright != 0 || f.contrast != 0
}
/// The picture's opacity, 0..=1 (1 − transparency).
pub fn opacity(&self) -> f32 {
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
}
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
/// black level (see the module docs). Non-finite values count as missing.
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
PicFx::new(fx(2.0 * black), fx(c), 0)
}
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
pub fn to_vml(&self) -> (f64, f64) {
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
}
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
fn slope(&self) -> f64 {
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
}
/// The lookup table every colour channel goes through.
pub fn table(&self) -> [u8; 256] {
let s = self.slope();
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
let mut t = [0u8; 256];
for (x, slot) in t.iter_mut().enumerate() {
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
}
t
}
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
/// trailing partial pixel is ignored.
pub fn apply_rgba(&self, rgba: &mut [u8]) {
if !self.changes_pixels() {
return;
}
let t = self.table();
for px in rgba.as_chunks_mut::<4>().0 {
for c in px.iter_mut().take(3) {
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
#[test]
fn matches_word() {
let cases: [(PicFx, [u8; 3]); 6] = [
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
];
for (fx, want) in cases {
let t = fx.table();
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
let got = t[x];
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
}
}
}
#[test]
fn channels_apart_alpha_kept() {
let fx = PicFx::new(80_000, -80_000, 0);
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
fx.apply_rgba(&mut px);
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
let mut odd = [0u8; 6];
fx.apply_rgba(&mut odd);
assert_eq!(odd[4..], [0, 0]);
}
#[test]
fn vml_mapping() {
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
let fx = PicFx::from_vml(g, b);
assert_eq!(fx, fx.clamped(), "{g} {b}");
let _ = fx.table();
}
}
#[test]
fn hostile_values_clamp() {
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
let fx = PicFx { bright: v, contrast: v, transparency: v };
let _ = fx.table();
assert!((0.0..=1.0).contains(&fx.opacity()));
let mut px = [1u8, 2, 3, 4];
fx.apply_rgba(&mut px);
assert_eq!(px[3], 4);
}
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
// Contrast 100 %: a threshold at mid-grey, no NaN.
let t = PicFx::new(0, FX_MAX, 0).table();
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
}
}
+6 -3
View File
@@ -23,12 +23,15 @@ mod para_props;
mod table_props;
pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor,
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
};
#[cfg(test)]
+41 -2
View File
@@ -291,6 +291,10 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text
/// stresses words; `Some(EmphasisMark::None)` turns off one a style sets.
#[serde(skip_serializing_if = "Option::is_none")]
pub emphasis: Option<EmphasisMark>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -381,6 +385,41 @@ pub struct EastAsianLayout {
pub vert_compress: Option<bool>,
}
/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis
/// mark.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum EmphasisMark {
#[default]
None,
/// A dot above.
Dot,
/// A comma (sesame) above.
Comma,
/// A hollow circle above.
Circle,
/// A dot below.
UnderDot,
}
impl EmphasisMark {
pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot];
/// OOXML `w:em` value.
pub fn ooxml(self) -> &'static str {
match self {
EmphasisMark::None => "none",
EmphasisMark::Dot => "dot",
EmphasisMark::Comma => "comma",
EmphasisMark::Circle => "circle",
EmphasisMark::UnderDot => "underDot",
}
}
/// The mark an OOXML `w:em` value names; unknown values are none.
pub fn from_ooxml(v: &str) -> EmphasisMark {
EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default()
}
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -466,7 +505,7 @@ impl CharProps {
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid);
drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis);
}
fn minus_script_style(&mut self, base: &CharProps) {
@@ -497,7 +536,7 @@ impl CharProps {
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid);
overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
+74 -1
View File
@@ -26,6 +26,40 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -107,6 +141,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +167,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +188,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -183,6 +247,11 @@ impl Borders {
pub fn any_visible(&self) -> bool {
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
}
/// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps
/// its room in LibreOffice's layout).
pub fn takes_room(&self) -> bool {
self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0)
}
/// Apply every `Some` side of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &Borders) {
if patch.top.is_some() {
@@ -363,6 +432,10 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -383,7 +456,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+38
View File
@@ -158,6 +158,12 @@ pub struct TableProps {
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
/// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and
/// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
}
/// Where a floating table sits; the text after it wraps around it.
@@ -294,6 +300,38 @@ pub struct CellProps {
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
/// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has
/// none).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<Box<CellPicture>>,
}
/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched
/// over the cell, and cut off at the cell's edges.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CellPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Its own size, points.
pub size: [f32; 2],
pub repeat: PictureRepeat,
/// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right
/// (bottom) edge.
pub align: [f32; 2],
}
/// How a background picture fills its area (ODF §20.348 `style:repeat`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PictureRepeat {
/// Once, at its own size.
#[default]
None,
/// Repeated across the area.
Tile,
/// Stretched over the area.
Stretch,
}
impl CellProps {
+65
View File
@@ -41,6 +41,9 @@ pub struct ResolvedChar {
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// A mark over or under each character (Font › Emphasis mark).
#[serde(default)]
pub emphasis: crate::props::EmphasisMark,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
@@ -97,6 +100,7 @@ impl ResolvedChar {
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
emphasis: c.emphasis.unwrap_or_default(),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
@@ -178,6 +182,10 @@ pub struct ResolvedPara {
pub tabs: Vec<TabStop>,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
#[serde(default)]
pub padding: [f32; 4],
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
@@ -265,6 +273,10 @@ impl ResolvedPara {
tabs,
shading: p.shading,
borders: p.borders.filter(Borders::any_visible),
padding: p.borders.map_or([0.0; 4], |b| {
[b.top, b.left, b.bottom, b.right]
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
}),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
@@ -323,6 +335,38 @@ impl StyleSheet {
(pp, cp)
}
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
/// that the chain doesn't set. Formats without table styles state these directly.
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
let mut with = self.default_para.clone();
with.overlay(&ParaProps { style: None, ..table.clone() });
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
diff_patch(&with, &without)
}
/// What a table style's run formatting `table` changes for a run of character style
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
/// chains are applied over it (ECMA-376 §17.7.2).
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
let chain = |defaults: CharProps| {
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
if let Some(cs) = run_style {
for s in self.chain(cs) {
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
cp.overlay(&s.chr);
}
}
}
cp
};
let mut with = self.default_chr.clone();
with.overlay(&CharProps { style: None, ..table.clone() });
diff_patch(&chain(with), &chain(self.default_chr.clone()))
}
/// Resolve a paragraph's properties.
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
self.resolve_para_in(p, None)
@@ -387,6 +431,16 @@ impl StyleSheet {
}
}
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
/// kind), as a set of only those; empty when serialisation fails.
fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, without: &T) -> T {
let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else {
return T::default();
};
let patch: serde_json::Map<String, serde_json::Value> = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect();
serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -524,4 +578,15 @@ mod tests {
assert_eq!(p.tabs.len(), 3);
assert_eq!(p.tabs[0].pos, 100.0);
}
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
#[test]
fn resolved_para_without_padding_reads() {
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
let mut v = serde_json::to_value(&p).unwrap();
v.as_object_mut().unwrap().remove("padding");
let back: ResolvedPara = serde_json::from_value(v).unwrap();
assert_eq!(back.padding, [0.0; 4]);
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
}
}
+19
View File
@@ -733,6 +733,13 @@ pub struct SectionProps {
/// Distance from the page edge to the header / footer.
pub header: f32,
pub footer: f32,
/// The space kept between a header and the body, and between the body and a footer, points
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
/// its spacing, so it only moves the body once content and spacing reach past the margin.
#[serde(skip_serializing_if = "is_zero")]
pub header_gap: f32,
#[serde(skip_serializing_if = "is_zero")]
pub footer_gap: f32,
pub gutter: f32,
/// Mirror margins (inside/outside) — set from the document settings.
pub columns: Columns,
@@ -772,6 +779,11 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub balance_columns: Option<bool>,
}
impl Default for SectionProps {
@@ -788,6 +800,8 @@ impl Default for SectionProps {
margin_right: 72.0,
header: 36.0,
footer: 36.0,
header_gap: 0.0,
footer_gap: 0.0,
gutter: 0.0,
columns: Columns::default(),
headers: HeaderSet::default(),
@@ -807,10 +821,15 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
balance_columns: None,
}
}
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
+22
View File
@@ -46,6 +46,10 @@ pub struct CustomProp {
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
/// a document property: written back as one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub odf_user_field: bool,
}
/// Document properties (File › Info).
@@ -162,6 +166,23 @@ pub struct Settings {
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<OdfLayout>,
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
/// the file says about laying it out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
/// explicit stops are held from the margin either way; the default ones are counted from the
/// indent.
pub tabs_from_indent: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
@@ -307,6 +328,7 @@ impl Default for Settings {
zoom: None,
remove_personal_info: false,
remove_date_time: false,
odf: None,
}
}
}
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+34
View File
@@ -232,6 +232,40 @@ impl PartSet {
}
}
/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give
/// their left/right edges at the table's sides and `inside_v` between cells; column parts their
/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their
/// four edges.
pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders {
let mut out = Borders::default();
for (part, b) in parts {
let b = *b;
let mapped = if part.is_row() {
Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
}
} else if part.is_column() {
Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
}
} else {
Borders { between: None, inside_v: None, ..b }
};
out.overlay(&mapped);
}
out
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
+6 -4
View File
@@ -71,10 +71,12 @@ pub struct Table {
/// Hard limits that keep hostile input from exhausting memory.
pub const MAX_ROWS: usize = 32_767;
pub const MAX_COLS: usize = 63;
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
/// when it is saved, copied or dropped (#1596).
pub const MAX_TABLE_DEPTH: usize = 24;
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
pub const MAX_TABLE_DEPTH: usize = 18;
impl Table {
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
+12 -11
View File
@@ -1,4 +1,5 @@
use super::*;
use std::sync::Arc;
mod note_conversion;
@@ -35,8 +36,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let bul_id = d.numbering.add_list(ListKind::Bullet);
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
@@ -60,9 +61,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
#[test]
fn word_count_includes_numbered_labels_with_nothing_suffix() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
@@ -81,9 +82,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
#[test]
fn word_count_keeps_a_cjk_list_label_one_word() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].format = section::NumFormat::ChineseCounting;
a.levels[0].text = "第%1章".into();
a.levels[0].start = 12;
@@ -187,7 +188,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
let n = &mut d.numbering;
let n = Arc::make_mut(&mut d.numbering);
n.add_list(ListKind::Bullet);
n.add_list(ListKind::Bullet);
n.abstracts[1].id = u32::MAX;
@@ -229,11 +230,11 @@ fn same_content_sees_every_change() {
assert!(!d.same_content(&e), "the formatting changed");
// The styles changed.
let mut e = d.clone();
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
assert!(!d.same_content(&e), "a style changed");
// The lists changed.
let mut e = d.clone();
e.numbering.add_list(ListKind::Numbered);
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
assert!(!d.same_content(&e), "a list was added");
// The settings changed.
let mut e = d.clone();
@@ -452,9 +453,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
fn adopting_relinks_lists_in_deeply_nested_tables() {
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
let mut ours = Document::from_text("ours");
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
let mut theirs = Document::from_text("theirs");
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let mut p = Paragraph::with_text("item", CharProps::default());
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
+28
View File
@@ -0,0 +1,28 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
#[inline(always)]
pub(crate) fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
+8 -7
View File
@@ -109,12 +109,13 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
ICO.get(i as usize).copied()
}
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
/// reader maps them.
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in
/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap
/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them.
fn brc_style(t: u8) -> BorderStyle {
match t {
0x00 => BorderStyle::None,
0x02 => BorderStyle::Thick,
0x03 => BorderStyle::Double,
0x06 => BorderStyle::Dotted,
0x07 | 0x16 => BorderStyle::Dashed,
@@ -125,15 +126,15 @@ fn brc_style(t: u8) -> BorderStyle {
}
}
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
/// 1-point steps for page borders, which never reach a paragraph property).
/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz`
/// reads (1-point steps for page borders, which never reach a paragraph property).
fn brc_width(t: u8, dpt: u8) -> f32 {
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) }
}
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
/// truncated operand is ignored.
fn brc80(op: &[u8]) -> Option<Border> {
pub(crate) fn brc80(op: &[u8]) -> Option<Border> {
let [dpt, ty, ico_index, tail, ..] = op else { return None };
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
return Some(Border::default());
+6 -82
View File
@@ -25,7 +25,9 @@ mod stsh;
mod table;
use std::io::{Read, Seek};
use std::sync::Arc;
use wordcraft_doc::link::hyperlink_target;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::Paragraph;
@@ -248,9 +250,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.core = core;
}
doc.body.clear();
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
doc.numbering = list::parse(&table, &fib, &fonts);
let sheet: StyleSheet = doc.styles.clone();
doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts));
doc.numbering = Arc::new(list::parse(&table, &fib, &fonts));
let sheet: StyleSheet = StyleSheet::clone(&doc.styles);
let sects = sections::parse(&word, &table, &fib);
let stories = sections::header_stories(&table, &fib);
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
@@ -621,6 +623,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
alt,
float: Default::default(),
crop: p.crop,
fx: Default::default(),
ole: None,
});
}
@@ -722,85 +725,6 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
/// (`#bookmark` for internal links); `None` when not a hyperlink.
fn hyperlink_target(instr: &str) -> Option<String> {
let t = instr.trim();
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
} else {
return None;
};
if rest.is_empty() {
return None;
}
let mut url: Option<String> = None;
let mut local: Option<String> = None;
let mut switch: Option<&str> = None;
let mut i = 0;
let bytes = rest.as_bytes();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if bytes[i] == b'\\' {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
i += 1;
}
switch = Some(rest.get(start..i).unwrap_or(""));
continue;
}
let arg = if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let val = rest.get(start..i).unwrap_or("");
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
}
val.to_string()
} else {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
rest.get(start..i).unwrap_or("").to_string()
};
let clean_arg = arg.replace("%22", "\"");
match switch.take() {
Some("\\l") => local = Some(clean_arg),
Some(_) => {}
None => {
if url.is_none() {
url = Some(clean_arg);
}
}
}
}
match (url, local) {
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
let frag = l.trim_start_matches('#');
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
}
(Some(u), _) if !u.is_empty() => Some(u),
(None, Some(l)) if !l.is_empty() => {
let frag = l.trim_start_matches('#');
Some(format!("#{frag}"))
}
_ => None,
}
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
fn pic_location(chpx: &[u8]) -> Option<u32> {
for prl in sprm::iter(chpx) {
+3 -23
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use wordcraft_doc::Rgb;
use wordcraft_doc::para::Paragraph;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, para_block};
@@ -458,32 +458,12 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {
}
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
/// left, bottom, right, inside-horizontal, inside-vertical.
/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are.
fn table_borders(operand: &[u8]) -> Option<Borders> {
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80);
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80);
Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) })
}
/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte.
fn brc80(b: &[u8]) -> Option<Border> {
let [w, ty, ico, flags] = b else { return None };
let style = match ty {
0x00 => BorderStyle::None,
0x02 | 0x15 => BorderStyle::Thick,
0x03 => BorderStyle::Double,
0x04 | 0x05 => BorderStyle::Dotted,
0x06 | 0x07 => BorderStyle::Dashed,
0x08 | 0x09 => BorderStyle::DotDash,
0x0A | 0x18 => BorderStyle::Wave,
0x11 => BorderStyle::Triple,
_ => BorderStyle::Single,
};
if *ty == 0 && *w == 0 {
return None;
}
Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 })
}
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
+71 -7
View File
@@ -1209,6 +1209,53 @@ fn table_merges_borders_shading() {
assert_eq!(top.width, 0.5);
}
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
#[test]
fn table_borders_read_border_types_as_paragraphs_do() {
use wordcraft_doc::props::BorderStyle as S;
let text = "a\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let row_end = {
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
v.extend_from_slice(&0xD605u16.to_le_bytes());
v.push(0x18);
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
v.extend_from_slice(&brc);
}
v
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
let b = tbl.props.borders.expect("table borders");
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
let want = [
(S::Dotted, true, 1.0),
(S::Triple, true, 1.0),
(S::Wave, true, 1.0),
(S::None, false, 0.0),
(S::Single, true, 12.0),
(S::None, false, 0.25),
];
assert_eq!(seen, want.map(Some));
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
}
// ---------------------------------------------------------------------------------------------
// D3: lists (PlfLst + PlfLfo) and the combined document.
@@ -1469,6 +1516,16 @@ fn hyperlink_target_needs_the_whole_field_name() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
}
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
#[test]
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
}
#[test]
fn bookmarks_become_objects() {
let text = "Hello world\r";
@@ -2482,17 +2539,21 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
}
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
/// border (type 0x02) stays thick in both, as tables read it too.
#[test]
fn paragraph_borders_read_from_both_forms() {
use wordcraft_doc::props::BorderStyle as S;
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
let thick80 = [8u8, 0x02, 0x06, 0x01];
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
let text = "boxed\r\r";
let spec = StructSpec {
text,
papx_entries: vec![
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc)])),
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
],
..structured_spec(text, text.chars().count() as u32)
};
@@ -2500,13 +2561,16 @@ fn paragraph_borders_read_from_both_forms() {
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
let b = p0.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
let top = p1.props.borders.expect("borders").top.expect("top border");
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
let b = p1.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
+1
View File
@@ -391,6 +391,7 @@ fn a_floating_copy_is_charged_its_alt_text() {
alt,
float: Default::default(),
crop: Default::default(),
fx: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
+14 -2
View File
@@ -25,11 +25,23 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: kind.to_string(),
value: v.text(),
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: "raw".into(),
value: xml,
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
};
out.push(prop);
}
+41 -32
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -172,8 +172,9 @@ impl PropCtx {
}
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
/// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL
/// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b,
/// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one.
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<Rgb> {
let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i);
let slot = match e.attr(attr)? {
@@ -199,8 +200,7 @@ impl PropCtx {
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
if let Some(t) = byte(tint) {
c = adjust_lum(c, |l| l * t + (1.0 - t));
}
if let Some(s) = byte(shade) {
} else if let Some(s) = byte(shade) {
c = adjust_lum(c, |l| l * s);
}
Some(c)
@@ -208,6 +208,10 @@ impl PropCtx {
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
pub fn shd_fill(&self, e: &El) -> Option<Rgb> {
// `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78).
if e.attr("w:val") == Some("nil") {
return None;
}
if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") {
return Some(c);
}
@@ -250,6 +254,7 @@ impl PropCtx {
"w:cs" => c.cs = Some(on_off(k)),
"w:caps" => c.caps = Some(on_off(k)),
"w:smallCaps" => c.small_caps = Some(on_off(k)),
"w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()),
"w:strike" => c.strike = Some(on_off(k)),
"w:dstrike" => c.double_strike = Some(on_off(k)),
"w:outline" => c.outline = Some(on_off(k)),
@@ -282,7 +287,7 @@ impl PropCtx {
c.underline_color =
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
}
"w:bdr" => c.border = Some(border(k)),
"w:bdr" => c.border = Some(self.border(k)),
"w:shd" => c.shading = self.shd_fill(k),
"w:vertAlign" => {
c.vert_align = k.attr("w:val").map(|v| match v {
@@ -338,6 +343,7 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
@@ -361,7 +367,7 @@ impl PropCtx {
(None, None) => {}
}
}
"w:pBdr" => p.borders = Some(borders(k)),
"w:pBdr" => p.borders = Some(self.borders(k)),
"w:shd" => p.shading = self.shd_fill(k),
"w:tabs" => {
let tabs: Vec<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
@@ -476,29 +482,32 @@ fn shd_fill(e: &El) -> Option<Rgb> {
None
}
pub fn border(e: &El) -> Border {
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
let color = e.attr("w:color").and_then(Rgb::parse);
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
Border { style, width, color, space }
}
impl PropCtx {
/// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text.
pub fn border(&self, e: &El) -> Border {
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse));
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
Border { style, width, color, space }
}
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
pub fn borders(e: &El) -> Borders {
let mut b = Borders::default();
for k in e.els() {
match k.local() {
"top" => b.top = Some(border(k)),
"left" | "start" => b.left = Some(border(k)),
"bottom" => b.bottom = Some(border(k)),
"right" | "end" => b.right = Some(border(k)),
"between" | "insideH" => b.between = Some(border(k)),
"insideV" => b.inside_v = Some(border(k)),
_ => {}
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
pub fn borders(&self, e: &El) -> Borders {
let mut b = Borders::default();
for k in e.els() {
match k.local() {
"top" => b.top = Some(self.border(k)),
"left" | "start" => b.left = Some(self.border(k)),
"bottom" => b.bottom = Some(self.border(k)),
"right" | "end" => b.right = Some(self.border(k)),
"between" | "insideH" => b.between = Some(self.border(k)),
"insideV" => b.inside_v = Some(self.border(k)),
_ => {}
}
}
b
}
b
}
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
@@ -532,7 +541,7 @@ impl PropCtx {
}
"w:jc" => t.align = k.attr("w:val").map(align),
"w:tblInd" => t.indent = tw(k, "w:w"),
"w:tblBorders" => t.borders = Some(borders(k)),
"w:tblBorders" => t.borders = Some(self.borders(k)),
"w:shd" => t.shading = self.shd_fill(k),
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
"w:bidiVisual" => t.bidi_visual = on_off(k),
@@ -638,7 +647,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
"w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63),
"w:hMerge" => hcont = k.attr("w:val") != Some("restart"),
"w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue },
"w:tcBorders" => c.borders = Some(borders(k)),
"w:tcBorders" => c.borders = Some(pc.borders(k)),
"w:shd" => c.shading = pc.shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:hideMark" => c.hide_mark = on_off(k),
@@ -675,7 +684,7 @@ pub struct HfRef {
}
/// `w:sectPr` (header/footer references are returned for the caller to resolve).
pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
let mut s = SectionProps::default();
let mut refs = Vec::new();
for k in e.els() {
@@ -780,7 +789,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
_ => PageVAlign::Top,
}
}
"w:pgBorders" => s.page_borders = Some(borders(k)),
"w:pgBorders" => s.page_borders = Some(pc.borders(k)),
"w:docGrid" => {
let mut g = DocGrid::default();
if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) {
+3 -3
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in &mut first.runs {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
@@ -289,10 +289,10 @@ impl Reader<'_> {
}
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
let (mut sp, refs) = sectpr(s);
let (mut sp, refs) = sectpr(&self.pc, s);
// §17.13.5.32: the section's properties before a tracked change (no header references).
if let Some(ch) = s.child("w:sectPrChange") {
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default();
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
}
for r in refs {
+1 -1
View File
@@ -263,7 +263,7 @@ impl Reader<'_> {
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
+1 -63
View File
@@ -1,4 +1,5 @@
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
use wordcraft_doc::link::hyperlink_target;
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
@@ -24,69 +25,6 @@ fn is_range_instr(instr: &str) -> bool {
crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
}
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
/// `#bookmark`. None for any other field, or one with no target.
fn hyperlink_target(instr: &str) -> Option<String> {
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let args = field_args(instr);
let mut args = args.iter();
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let (mut url, mut anchor) = (None, None);
while let Some((a, quoted)) = args.next() {
if *quoted || !a.starts_with('\\') {
url = url.or(Some(a.as_str()));
continue;
}
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
if a.eq_ignore_ascii_case("\\l") {
anchor = value.map(|(v, _)| v.as_str());
}
}
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
match (url, anchor) {
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
(Some(u), None) => Some(u.to_string()),
(None, Some(a)) => Some(format!("#{a}")),
(None, None) => None,
}
}
/// A field code's words: the field's name, its switches and its arguments, each with whether
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
fn field_args(instr: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
let mut word = String::new();
if c == '"' {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
_ => word.push(c),
}
}
out.push((word, true));
} else {
word.push(c);
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
word.push(c);
}
out.push((word, false));
}
}
out
}
impl Reader<'_> {
// ---- fields ----
+4 -1
View File
@@ -134,7 +134,10 @@ impl Reader<'_> {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
+7 -6
View File
@@ -2,6 +2,7 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
@@ -186,7 +187,7 @@ impl Reader<'_> {
if !sheet.styles.iter().any(|s| s.quick) {
sheet.add_default_gallery();
}
self.doc.styles = sheet;
self.doc.styles = Arc::new(sheet);
Ok(())
}
@@ -196,7 +197,7 @@ impl Reader<'_> {
let mut t = TableStyleParts::default();
if let Some(tp) = s.child("w:tblPr") {
if let Some(b) = tp.child("w:tblBorders") {
t.borders = Some(props::borders(b));
t.borders = Some(self.pc.borders(b));
}
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
@@ -210,7 +211,7 @@ impl Reader<'_> {
let f = PartFormat {
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)),
align: ppr.as_ref().and_then(|p| p.align),
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
"center" => VAlign::Center,
@@ -229,7 +230,7 @@ impl Reader<'_> {
t.fill = f.fill;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
t.borders = Some(self.pc.borders(b));
}
if f.v_align.is_some() {
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
@@ -298,8 +299,8 @@ impl Reader<'_> {
a.levels = levels;
}
}
self.doc.numbering.abstracts = abstracts;
self.doc.numbering.nums = nums;
Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts;
Arc::make_mut(&mut self.doc.numbering).nums = nums;
Ok(())
}
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+23
View File
@@ -84,6 +84,29 @@ pub fn int(s: &str) -> Option<i64> {
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
+15 -2
View File
@@ -317,7 +317,8 @@ impl<'a> Conv<'a> {
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
@@ -329,7 +330,19 @@ impl<'a> Conv<'a> {
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
let fx = crate::units::blip_fx(blip);
if fx.changes_pixels()
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
{
let (gain, black) = fx.to_vml();
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
data.attrs.push(("gain".into(), fixed(gain)));
data.attrs.push(("blacklevel".into(), fixed(black)));
}
kids.push(Node::El(shape));
vec![Node::El(plain("w:pict", kids))]
}
+4 -2
View File
@@ -294,8 +294,10 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels,
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
// VML has no centred fill (an ODT page background's): stretched to the page.
let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() };
if !kind.is_empty() {
fa.push(("type", kind));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
+7
View File
@@ -108,6 +108,10 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
}
toggle(w, "w:rtl", c.rtl);
toggle(w, "w:cs", c.cs);
// Emphasis mark (§17.3.2.12), after `w:cs` and before `w:lang`.
if let Some(m) = c.emphasis {
w.val("w:em", m.ooxml());
}
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
if let Some(l) = &c.lang {
lang_attrs.push(("w:val", l));
@@ -283,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
+16 -2
View File
@@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> {
impl Writer<'_> {
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
@@ -26,7 +26,21 @@ impl Writer<'_> {
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
let fx = fx.clamped();
if fx.is_identity() {
w.empty("a:blip", &[("r:embed", &rid)]);
} else {
w.open("a:blip", &[("r:embed", &rid)]);
if fx.transparency != 0 {
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
}
if fx.bright != 0 || fx.contrast != 0 {
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
}
w.close("a:blip");
}
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
+1 -1
View File
@@ -249,7 +249,7 @@ impl Writer<'_> {
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object())
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
+10 -1
View File
@@ -317,7 +317,16 @@ fn a_chunks_pictures_come_with_it() {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
let image = InlineObject::Image {
media: key,
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
+2 -1
View File
@@ -7,6 +7,7 @@
//! in a right-to-left paragraph.
use std::io::Read;
use std::sync::Arc;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
@@ -189,6 +190,6 @@ fn a_language_the_run_already_has_is_kept() {
#[test]
fn an_inherited_bidi_language_is_not_repeated() {
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into());
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+5 -4
View File
@@ -2,6 +2,7 @@
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
//! the reader reads an omitted size as 10 pt.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::Document;
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
fn resolved(d: &Document) -> f32 {
@@ -19,7 +20,7 @@ fn defaults(xml: &str) -> &str {
#[test]
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
let mut d = Document::new();
d.styles.default_chr.size = None;
Arc::make_mut(&mut d.styles).default_chr.size = None;
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
let bytes = wordcraft_docx::write(&d).unwrap();
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
@@ -33,7 +34,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
fn an_explicit_default_size_is_written_as_it_is() {
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
let mut d = Document::new();
d.styles.default_chr.size = Some(size);
Arc::make_mut(&mut d.styles).default_chr.size = Some(size);
let bytes = wordcraft_docx::write(&d).unwrap();
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
}
@@ -59,8 +60,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() {
// The written default is the resolver's own fallback, not what the Normal style resolves to,
// so a style that sets a size keeps it and one that does not still falls back to the default.
let mut d = Document::new();
d.styles.default_chr.size = None;
if let Some(normal) = d.styles.get_mut("Normal") {
Arc::make_mut(&mut d.styles).default_chr.size = None;
if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") {
normal.chr.size = Some(14.0);
}
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
+1
View File
@@ -22,6 +22,7 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::EmphasisMark;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(xml.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect()
}
#[test]
fn emphasis_marks_are_read_and_saved() {
let run = |t: &str, em: &str| format!(r#"<w:r><w:rPr><w:cs/>{em}<w:lang w:val="ja-JP"/></w:rPr><w:t>{t}</w:t></w:r>"#);
let body = format!(
"<w:p>{}{}{}{}{}{}</w:p>",
run("a", r#"<w:em w:val="dot"/>"#),
run("b", r#"<w:em w:val="comma"/>"#),
run("c", r#"<w:em w:val="circle"/>"#),
run("d", r#"<w:em w:val="underDot"/>"#),
run("e", r#"<w:em w:val="none"/>"#),
run("f", r#"<w:em w:val="sparkle"/>"#),
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
// An unknown mark is none (and the run with it joins the one before, now alike).
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
assert_eq!(marks(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
// In its place: after w:cs, before w:lang.
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
}
+216 -1
View File
@@ -1,6 +1,7 @@
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
use std::io::Write;
use std::sync::Arc;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
@@ -252,7 +253,7 @@ fn omitted_paragraph_defaults_can_be_edited() {
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert!(!d.styles.default_para_present);
d.styles.default_para.space_after = Some(12.0);
Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(again.styles.default_para_present);
assert_eq!(again.styles.default_para.space_after, Some(12.0));
@@ -2169,6 +2170,68 @@ fn theme_colour_wins_over_the_fixed_colour() {
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
}
/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name.
#[test]
fn nil_shading_paints_no_fill() {
let body = r#"
<w:tbl><w:tblGrid><w:gridCol w:w="200"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr>
<w:tc><w:tcPr><w:shd w:val="nil" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc>
<w:tc><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
<w:p><w:pPr><w:shd w:val="nil" w:fill="C00000" w:themeFill="accent2"/></w:pPr><w:r><w:rPr><w:shd w:val="nil" w:color="C00000" w:fill="C00000"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
assert_eq!(t.rows[0].cells[0].props.shading, None);
assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0)));
let p = paras(&d);
assert_eq!(p[0].props.shading, None);
assert_eq!(p[0].props_of_char(0).shading, None);
}
/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black.
#[test]
fn border_theme_colour_wins_over_the_fixed_colour() {
let body = r#"
<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="background1"/><w:left w:val="single" w:sz="4" w:color="FF0000"/></w:pBdr></w:pPr><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:color="auto" w:themeColor="accent2"/></w:rPr><w:t>x</w:t></w:r></w:p>
<w:tbl><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="accent1"/></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders><w:left w:val="single" w:sz="4" w:color="000000" w:themeColor="accent6" w:themeShade="BF"/></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
<w:p/>
<w:sectPr><w:pgBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="text2"/></w:pgBorders></w:sectPr>"#;
let styles = r#"<w:style w:type="table" w:styleId="T"><w:name w:val="T"/><w:tblPr><w:tblBorders><w:bottom w:val="single" w:sz="4" w:color="000000" w:themeColor="accent3"/></w:tblBorders></w:tblPr></w:style>"#;
let d = themed_docx(body, styles, None);
let p = paras(&d);
let pb = p[0].props.borders.unwrap();
assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF)));
assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one");
assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31)));
let Block::Table(t) = &*d.body[1] else { panic!("table second") };
assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4)));
let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color);
assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}");
assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A)));
let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders);
assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5)));
}
/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours,
/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a).
#[test]
fn theme_tint_wins_over_theme_shade() {
let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#;
let body = format!(
r#"<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" {both}/></w:pBdr></w:pPr><w:r><w:rPr><w:color w:val="000000" {both}/><w:bdr w:val="single" w:sz="4" w:color="000000" {both}/><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent1" w:themeFillTint="99" w:themeFillShade="7F"/></w:rPr><w:t>a</w:t></w:r></w:p>"#
);
let d = themed_docx(&body, "", None);
let p = paras(&d);
let ch = p[0].props_of_char(0);
// accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val.
let tinted = Rgb(0x8E, 0xAA, 0xDB);
assert!(near(ch.color, tinted), "text colour {:?}", ch.color);
let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb);
assert!(near(run_border, tinted), "text border {run_border:?}");
let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb);
assert!(near(para_border, tinted), "paragraph border {para_border:?}");
assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading);
}
#[test]
fn theme_colours_follow_the_colour_scheme_mapping() {
// Styles are read before settings; the mapping still applies to them.
@@ -3878,6 +3941,144 @@ fn hyperlink_with_empty_target_is_no_link() {
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
}
/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's
/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back.
#[test]
fn picture_brightness_contrast_transparency() {
use wordcraft_doc::picfx::PicFx;
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
let vml =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape style="width:1in;height:36pt"><v:imagedata r:id="rIdImg" {attrs}/></v:shape></w:pict></w:r>"#);
let dml = |blip: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg">{blip}</a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let cases: Vec<(String, Option<PicFx>)> = vec![
// Word's Washout: 0.3 and 0.35 in 16.16 fixed point.
(vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))),
(vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))),
(vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))),
(vml(""), Some(PicFx::default())),
(dml(r#"<a:lum bright="80000" contrast="-80000"/>"#), Some(PicFx::new(80_000, -80_000, 0))),
(dml(r#"<a:alphaModFix amt="40000"/><a:lum bright="-20000"/>"#), Some(PicFx::new(-20_000, 0, 60_000))),
(dml(""), Some(PicFx::default())),
// Hostile values: clamped, never a panic.
(vml(r#"gain="NaNf" blacklevel="infinity""#), None),
(vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None),
(vml(r#"gain="1e308" blacklevel="-1e308""#), None),
(vml(r#"gain="f" blacklevel="--3""#), None),
(dml(r#"<a:lum bright="999999999" contrast="-99999999999999999999"/><a:alphaModFix amt="-5"/>"#), None),
(dml(r#"<a:alphaModFix amt="250000"/><a:lum bright="x" contrast=""/>"#), None),
];
let body: String = cases.iter().map(|(x, _)| format!("<w:p>{x}</w:p>")).collect();
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdImg", "image", "media/pic.png")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/media/pic.png", png),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
let fx_of = |d: &Document| -> Vec<PicFx> {
paras(d)
.iter()
.map(|p| match p.objects.first() {
Some(InlineObject::Image { fx, .. }) => *fx,
o => panic!("{o:?}"),
})
.collect()
};
let read = fx_of(&d);
assert_eq!(read.len(), cases.len());
for ((x, want), got) in cases.iter().zip(&read) {
assert_eq!(*got, got.clamped(), "{x}");
if let Some(want) = want {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}");
}
}
// Clamped hostile values.
assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000));
assert_eq!(read[12], PicFx::default());
// Saved as DrawingML (VML pictures too) and read back unchanged.
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(fx_of(&back), read);
}
/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks
/// building block keeps its brightness and contrast.
#[test]
fn washout_picture_watermark_in_header() {
let hdr = format!(
r##"<w:hdr {W_NS}><w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Watermarks"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:200pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rImg" o:title="wm" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p></w:sdtContent></w:sdt></w:hdr>"##
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let id = d.last_section.headers.default.unwrap();
let fx = d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.expect("watermark picture");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads
/// and is written back once, as in the body.
#[test]
fn faded_picture_behind_text_in_header() {
let hdr = format!(
r#"<w:hdr {W_NS}><w:p><w:r><w:drawing><wp:anchor behindDoc="1" distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="2540000" cy="2540000"/><wp:wrapNone/><wp:docPr id="7" name="Faded"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rImg"><a:alphaModFix amt="20000"/></a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p></w:hdr>"#
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let header_picture = |d: &wordcraft_doc::Document| {
let id = d.last_section.headers.default.unwrap();
d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None })
.expect("header picture")
};
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let (fx, wrap) = header_picture(&d);
assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000));
assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText);
let saved = wordcraft_docx::write(&d).unwrap();
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving");
}
/// An OLE object's preview keeps the corrections of its picture (#1961 review).
#[test]
fn ole_preview_keeps_its_corrections() {
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rIdImg" gain="19661f" blacklevel="22938f"/></v:shape></w:object></w:r></w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap();
let fx = d.body[0]
.as_para()
.and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }))
.expect("the preview");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
#[test]
fn row_table_borders_round_trip() {
let d = read_body(
@@ -4313,6 +4514,20 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+2 -1
View File
@@ -4,6 +4,7 @@
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
@@ -191,7 +192,7 @@ fn glossary_entries_are_read_into_the_model() {
fn edited_glossary_is_written_from_the_model() {
let mut doc = wordcraft_docx::read(&with_glossary()).unwrap();
doc.glossary[0].name = "Sign-off".into();
let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let mut listed = Paragraph::with_text("Second line", CharProps::default());
listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
doc.glossary.push(BuildingBlock {
+3
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -13,6 +14,7 @@ mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
mod emphasis_marks;
mod fixtures;
mod glossary;
mod heading_levels;
@@ -31,6 +33,7 @@ mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
+3 -2
View File
@@ -5,6 +5,7 @@
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::Arc;
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
@@ -272,7 +273,7 @@ fn comment_references_use_the_documents_annotation_reference_style() {
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
// A document without that style keeps the English id.
let mut doc = wordcraft_doc::Document::new();
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
doc.body = rebuilt.body.clone();
doc.parts = rebuilt.parts.clone();
@@ -314,7 +315,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
let mut copy = doc.numbering.abstracts[0].clone();
copy.id = 7;
doc.numbering.abstracts.push(copy);
Arc::make_mut(&mut doc.numbering).abstracts.push(copy);
let n = text(&save(&doc), "word/numbering.xml");
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
}
+52 -16
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -95,6 +95,7 @@ fn every_char_prop_round_trips() {
scale: Some(150.0),
position: Some(3.0),
kern: Some(8.0),
emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma),
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
outline: Some(true),
shadow: Some(true),
@@ -220,6 +221,7 @@ fn every_para_prop_round_trips() {
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
text_alignment: Some(TextAlignment::Top),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
@@ -248,7 +250,7 @@ fn every_para_prop_round_trips() {
#[test]
fn styles_and_lists_round_trip() {
let mut d = Document::new();
d.styles.upsert(Style {
Arc::make_mut(&mut d.styles).upsert(Style {
id: "MyStyle".into(),
name: "My Style".into(),
kind: StyleKind::Paragraph,
@@ -265,7 +267,7 @@ fn styles_and_lists_round_trip() {
default: false,
table: None,
});
d.styles.upsert(Style {
Arc::make_mut(&mut d.styles).upsert(Style {
id: "MyStyleChar".into(),
name: "My Style Char".into(),
kind: StyleKind::Character,
@@ -274,10 +276,10 @@ fn styles_and_lists_round_trip() {
hidden: true,
..Default::default()
});
let bullets = d.numbering.add_list(ListKind::Bullet);
let nums = d.numbering.add_list(ListKind::Legal);
let outline = d.numbering.add_list(ListKind::Outline);
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal);
let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) {
n.start_overrides.push((0, 4));
}
let mut ps = Vec::new();
@@ -427,6 +429,7 @@ fn images_round_trip() {
// Room for a shadow: kept through save and load.
float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() },
crop: [0.1, 0.0, 0.25, 0.05],
fx: Default::default(),
ole: None,
};
let floating = InlineObject::Image {
@@ -436,6 +439,7 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let aligned = InlineObject::Image {
@@ -455,6 +459,7 @@ fn images_round_trip() {
..Default::default()
},
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut floats = vec![floating.clone(), aligned];
@@ -466,6 +471,7 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
});
}
@@ -567,7 +573,7 @@ fn table_style_formatting_round_trips() {
band_size: Some(3),
..Default::default()
};
d.styles.upsert(Style {
Arc::make_mut(&mut d.styles).upsert(Style {
id: "Whole".into(),
name: "Whole".into(),
kind: StyleKind::Table,
@@ -620,7 +626,7 @@ fn every_table_style_part_round_trips() {
};
st.set_table_part(part, f);
}
d.styles.upsert(st.clone());
Arc::make_mut(&mut d.styles).upsert(st.clone());
let r = rt(&d);
let got = r.styles.get("Parts").unwrap();
for part in TablePart::ALL {
@@ -1188,7 +1194,16 @@ fn groups_round_trip() {
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "A picture".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
@@ -1275,7 +1290,16 @@ fn drawing_canvas_round_trips() {
use wordcraft_doc::para::{CanvasStyle, GroupChild};
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "In the canvas".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let star = InlineObject::Shape {
kind: ShapeKind::Star,
w: 72.0,
@@ -1708,7 +1732,7 @@ fn kitchen_sink() -> Document {
let mut d = Document::new();
let png = tiny_png();
let key = d.add_media(png, "png");
let num = d.numbering.add_list(ListKind::Numbered);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
@@ -1733,7 +1757,16 @@ fn kitchen_sink() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None },
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "pic".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
)
.unwrap();
@@ -1828,6 +1861,7 @@ fn hostile_model_values_still_write() {
alt: String::new(),
float: Float::default(),
crop: [f32::NAN; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
@@ -1940,10 +1974,10 @@ fn list_level_overrides_round_trip() {
use wordcraft_doc::numbering::{Counters, Level};
use wordcraft_doc::section::NumFormat;
let mut d = Document::new();
let num = d.numbering.add_list(ListKind::Numbered);
let restart = d.numbering.restart(num).unwrap();
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap();
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) {
n.level_overrides = vec![(1, own.clone())];
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
@@ -2098,6 +2132,7 @@ fn rotation_and_flips_round_trip() {
alt: String::new(),
float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |kind, float| InlineObject::Shape {
@@ -2598,6 +2633,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
alt: "A kiln at dusk".into(),
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |alt: AltText| InlineObject::Shape {
+64
View File
@@ -0,0 +1,64 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
+1
View File
@@ -215,6 +215,7 @@ fn picture_in_box_keeps_its_frame() {
alt: "Synthetic marker".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut p = Paragraph::new();
+11 -1
View File
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
@@ -117,6 +117,16 @@ fn wordml_2003_sample() -> String {
)
}
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
/// contrast (#1711).
#[test]
fn word_2003_xml_picture_keeps_washout() {
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
let ps = paras(&doc);
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
}
#[test]
fn imports_word_2003_xml() {
let xml = wordml_2003_sample();
+53 -2
View File
@@ -32,7 +32,7 @@ fn sample() -> Document {
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = d.numbering.add_list(ListKind::Numbered);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
@@ -69,7 +69,16 @@ fn sample() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
)
.unwrap();
@@ -180,6 +189,48 @@ fn big_and_deep_documents_write() {
}
}
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
#[test]
fn picture_brightness_and_contrast_round_trip() {
use wordcraft_doc::picfx::PicFx;
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
d.body = cases
.iter()
.map(|fx| {
let mut p = Paragraph::new();
let pic = InlineObject::Image {
media: key.clone(),
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: *fx,
ole: None,
};
p.insert_object(0, pic, &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let xml = write_wordml2003(&d).expect("write");
let text = String::from_utf8_lossy(&xml);
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
let back = read_xml(&xml).expect("read");
let got: Vec<PicFx> = paras(&back)
.iter()
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.collect();
assert_eq!(got.len(), cases.len());
for (g, want) in got.iter().zip(cases) {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
}
}
#[test]
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
+2
View File
@@ -44,6 +44,8 @@ getrandom = { workspace = true }
# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store.
craft-font-store = { workspace = true }
craft-font-catalog = { workspace = true }
# The local time zone's offset for Insert ▸ Date & Time and date fields (#1707); the web asks the browser's Date.
chrono = { workspace = true, features = ["clock"] }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
+1
View File
@@ -70,6 +70,7 @@ Home|Font|Character Shading|format.shading
Home|Font|Font Dialog|format.fontDialog
Home|Font|Double Strikethrough|format.doubleStrikethrough
Home|Font|Small Caps|format.smallCaps
Home|Font|Emphasis Mark|format.emphasisMark
Home|Font|All Caps|format.allCaps
Home|Font|Hidden|format.hidden
Home|Font|Emboss|format.emboss
+53
View File
@@ -548,6 +548,59 @@ pub fn now_iso() -> String {
iso_from_unix_secs(now_unix())
}
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
pub fn local_offset_secs(unix: u64) -> i64 {
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_offset_secs(unix: u64) -> i64 {
use chrono::Offset;
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
return 0;
};
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
// panic hook (logged, no abort); the document is untouched.
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
}
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn system_offset_secs(unix: u64) -> i64 {
let ms = (unix as f64) * 1000.0;
if !ms.is_finite() {
return 0;
}
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
}
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
fn system_offset_secs(_unix: u64) -> i64 {
0
}
/// The local civil date and time now: see [`local_civil`].
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
let now = now_unix();
local_civil(now, local_offset_secs(now))
}
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
let days = local.div_euclid(86_400);
let t = local.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
}
/// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
+5 -2
View File
@@ -194,12 +194,15 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) ->
Ok(out)
}
/// Draw the chart of `o` again at its current size (after a resize), if it has a model.
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
/// saving writes that back with the look WordCraft doesn't model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
*graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h));
let source = graphic.source.clone();
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
}
}
+231 -5
View File
@@ -6,6 +6,7 @@ use super::{
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
};
use std::sync::Arc;
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
@@ -65,14 +66,60 @@ fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option
update_generated(s)
}
/// Most paragraphs a list held as a field around its entries spans.
const MAX_WRAPPED: usize = 10_000;
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
/// field that starts at body block `i` and runs over its entries: its field code and the block
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
.then(|| (k, off, t.to_string()))
}
_ => None,
})?;
// Only when it starts the paragraph: text before it is the document's, not the list's.
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
return None;
}
// Where it ends: the field end that closes it, nested fields counted from its own start (a
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
let mut depth = 0usize;
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
let Some(q) = b.as_para() else { continue };
for (at, o) in q.objects_with_offsets().skip(if j == i { k } else { 0 }) {
match o {
InlineObject::FieldStart { .. } => depth += 1,
InlineObject::FieldEnd => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
}
}
_ => {}
}
}
}
None
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
p.objects.iter().find_map(list_field)
}
/// The field code of `o` when it is a generated list's field.
fn list_field(o: &InlineObject) -> Option<String> {
match o {
InlineObject::Field { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
}
_ => None,
})
}
}
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
@@ -152,14 +199,64 @@ pub(super) const ENTRY_STYLE: &str = "ListEntry";
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
let mut i = 0;
while i < s.doc.body.len() {
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
// A list held as a field around its entries (as Word and ODT files hold them): made the
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
// What follows its closing field end, and its first paragraph's properties and section
// break, are the document's and stay.
// (The field it is made: what comes before it in its paragraph may hold other fields.)
let mut converted = None;
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
// The entries between its first and last paragraphs go.
for _ in i.saturating_add(1)..last {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
// and the field; its last (or, ending in its first, the
// same) loses what is up to and through the field end, and goes when nothing is left
// of it and it ends no section.
let first_end = if last > i {
let q = s.doc.para_mut(StoryRef::Body, &Path::top(i + 1))?;
q.delete(0, end)?;
if q.is_empty() && q.section.is_none() {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
s.doc.para_mut(StoryRef::Body, &Path::top(i))?.len()
} else {
end
};
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
p.delete(start, first_end)?;
converted = Some(instr.trim_start().to_string());
p.insert_object(
start,
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)?;
// What followed the field end in its own paragraph comes after the list, in a
// paragraph of its own that takes the section break.
let field = start + wordcraft_doc::para::OBJ.len_utf8();
if p.len() > field {
let rest = p.split_off(field)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
}
// Another list's field before it would be the paragraph's list on later updates (and
// have its shown list cleared with this one's): the list gets a paragraph of its own,
// after it, and each is rebuilt in its turn.
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
let list = p.split_off(start)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
converted = None;
}
}
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
let Some(kind) = kind else {
i += 1;
continue;
};
add_entry_style(s);
if kind.starts_with("INDEX") {
crate::cmd::index::rebuild(s, i, None)?;
crate::cmd::index::rebuild_from(s, i, None, kind)?;
i += 1;
continue;
}
@@ -222,7 +319,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
/// layout cache starts over (and the saved file would gain a style it doesn't use).
fn add_entry_style(s: &mut Session) {
if s.doc.styles.get(ENTRY_STYLE).is_none() {
s.doc.styles.upsert(wordcraft_doc::Style {
Arc::make_mut(&mut s.doc.styles).upsert(wordcraft_doc::Style {
id: ENTRY_STYLE.into(),
name: "List Entry".into(),
kind: wordcraft_doc::StyleKind::Paragraph,
@@ -332,3 +429,132 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
/// ends at its own field end, paragraphs below, not in its first paragraph.
#[test]
fn a_wrapped_list_ends_at_its_own_field_end() {
let mut d = Document::from_text("Old one\nOld two\nAfter");
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
/// in paragraph `last` at byte `at`.
fn wrapped(text: &str, first: usize, last: usize, at: usize) -> Session {
let mut d = Document::from_text(text);
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(last)) {
p.insert_object(at, InlineObject::FieldEnd, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(first)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
}
Session::new(d)
}
fn texts(s: &Session) -> Vec<String> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()
}
/// What follows a wrapped list's closing field end, in its last paragraph or in its only one,
/// stays, after the list; the last paragraph's section break stays too.
#[test]
fn what_follows_a_wrapped_lists_end_stays() {
// Ending in a later paragraph, with text after the end and a section break.
let mut s = wrapped("Old one\nOld two\nOld three. After\nBelow", 0, 2, "Old three.".len());
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(2)) {
p.section = Some(Box::default());
}
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.props.indent_left = Some(9.0);
}
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.iter().all(|t| !t.contains("Old")), "{t:?}");
assert!(t.iter().any(|t| t == " After"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
let after = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.plain_text() == " After");
assert!(after.is_some_and(|p| p.section.is_some()), "the section break went");
let first = s.doc.body.first().and_then(|b| b.as_para());
assert!(first.is_some_and(|p| p.props.indent_left == Some(9.0) && list_kind(p).is_some()), "{first:?}");
// An empty last paragraph without a section break goes.
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
update_generated(&mut s).unwrap();
// (The field's paragraph shows nothing: there are no sources to list.)
assert_eq!(texts(&s), ["", "Below"]);
// Ending in its own paragraph: what follows comes after the list.
let mut s = wrapped("Old entry. Then this\nBelow", 0, 0, "Old entry.".len());
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.first().is_some_and(|t| !t.contains("Old")), "{t:?}");
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
}
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
/// `PAGE` field) stays there, before the rebuilt list's field.
#[test]
fn what_comes_before_a_wrapped_lists_field_stays() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
let props = CharProps::default();
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
}
update_generated(&mut s).unwrap();
let first = s.doc.body.first().and_then(|b| b.as_para());
let kinds: Vec<&str> = first
.map(|p| {
p.objects
.iter()
.map(|o| match o {
InlineObject::BookmarkStart { .. } => "bookmark",
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
InlineObject::FieldEnd => "end",
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
_ => "other",
})
.collect()
})
.unwrap_or_default();
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
assert_eq!(texts(&s), ["", "Below"]);
}
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
/// wrapped list is the one rebuilt.
#[test]
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
let index =
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, index, &CharProps::default()).unwrap();
}
// Updated again, each list stays itself.
for _ in 0..2 {
update_generated(&mut s).unwrap();
let t = texts(&s);
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
}
}
}
+7 -4
View File
@@ -7,6 +7,7 @@
//! typing and deleting commands.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::control::{ContentControl, ControlKind, ControlLock, ControlRange, ListItem, MAX_CONTROL_TEXT, MAX_LIST_ITEMS};
use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::{CharProps, Rgb, TextColor};
@@ -172,7 +173,7 @@ fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String {
/// The placeholder style, added to the document when it hasn't one.
fn ensure_placeholder_style(s: &mut Session) {
if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() {
s.doc.styles.styles.push(Style {
Arc::make_mut(&mut s.doc.styles).styles.push(Style {
id: PLACEHOLDER_STYLE.into(),
name: PLACEHOLDER_STYLE_NAME.into(),
kind: StyleKind::Character,
@@ -799,6 +800,8 @@ mod tests {
#[cfg(test)]
mod editing_tests {
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::StoryRef;
use wordcraft_doc::control::ControlKind;
@@ -829,8 +832,8 @@ mod editing_tests {
let typed_style = |run_props: Option<CharProps>| {
let mut s = Session::new(wordcraft_doc::Document::new());
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey));
s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
Arc::make_mut(&mut s.doc.styles).styles.push(style("Platzhaltertext", "Placeholder Text", grey));
Arc::make_mut(&mut s.doc.styles).styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
run(&mut s, "text.insert", json!({"text": "Name: "}));
run(&mut s, "developer.plainText", json!({"tag": "name"}));
let start = s.doc.control_at(&s.sel.focus).unwrap().start;
@@ -852,7 +855,7 @@ mod editing_tests {
// style goes all the same.
let mut s = Session::new(wordcraft_doc::Document::new());
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
Arc::make_mut(&mut s.doc.styles).styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
run(&mut s, "text.insert", json!({"text": "Nom : "}));
run(&mut s, "developer.plainText", json!({"tag": "nom"}));
let r = s.doc.control_at(&s.sel.focus).unwrap();
+11 -9
View File
@@ -1,6 +1,7 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
@@ -95,8 +96,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
s.doc.settings.major_font = heading.into();
s.doc.settings.minor_font = body.into();
s.doc.styles.default_chr.font = Some(body.into());
for st in &mut s.doc.styles.styles {
Arc::make_mut(&mut s.doc.styles).default_chr.font = Some(body.into());
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
if let Some(f) = &st.chr.font
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
{
@@ -108,7 +109,7 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
}
fn set_accent(s: &mut Session, accent: Rgb) {
for st in &mut s.doc.styles.styles {
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|| st.id == "IntenseQuote"
|| st.id == "IntenseEmphasis"
@@ -173,15 +174,16 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
}
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
let sheet = Arc::make_mut(&mut s.doc.styles);
for mut st in fresh.styles {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
match sheet.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => s.doc.styles.styles.push(st),
None => sheet.styles.push(st),
}
}
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
@@ -200,9 +202,9 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
"double" => (0.0, 8.0, 2.0),
_ => (0.0, 8.0, 1.15),
};
s.doc.styles.default_para.space_before = Some(before);
s.doc.styles.default_para.space_after = Some(after);
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
Arc::make_mut(&mut s.doc.styles).default_para.space_before = Some(before);
Arc::make_mut(&mut s.doc.styles).default_para.space_after = Some(after);
Arc::make_mut(&mut s.doc.styles).default_para.line_spacing = Some(LineSpacing::Multiple(line));
sel_result(s)
}
@@ -227,7 +229,7 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = &mut s.doc.styles;
let st = Arc::make_mut(&mut s.doc.styles);
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
+1
View File
@@ -880,6 +880,7 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
};
*kind = new_kind;
*freeform = geometry;
extra.odf_geometry = None;
*fill = None;
let [_, _, raw_w, raw_h] = found.bounds();
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
+8 -5
View File
@@ -1,6 +1,7 @@
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::{Block, Document, StoryRef};
use super::{pos_json, sel_result};
@@ -127,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
r#"{"path": string, "pagesPerSheet"?: 1 | 2 | 4 | 6 | 9 | 16 | "booklet" | "bookletReverse", "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
@@ -230,7 +231,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
d.styles.default_chr.lang = Some(lang.clone());
Arc::make_mut(&mut d.styles).default_chr.lang = Some(lang.clone());
}
d
}
@@ -271,7 +272,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
doc.styles.default_chr.lang = Some(lang.clone());
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
match s.used_because() {
None => s.set_document(doc),
@@ -345,7 +346,9 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
return Err(CmdError::Params(
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
@@ -380,7 +383,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
+20 -1
View File
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
use wordcraft_doc::section::SectionProps;
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef};
use crate::Session;
@@ -212,6 +212,25 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
/// another author's is superseded.
pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return Ok(());
}
let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) };
let revisions = s.doc.revisions.clone();
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None };
if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) {
let p = s.doc.para_mut(story, path)?;
p.props.fmt_change = want;
p.touch();
}
Ok(())
}
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
/// differently from `old` as tracked formatting changes by `author`.
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
+104 -1
View File
@@ -1,4 +1,6 @@
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`; and
//! the fonts the document's text asks for that aren't installed, with the fonts drawn instead
//! (`font.missing`).
//!
//! An added font joins the process-wide font database on top of the bundled and system fonts
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
@@ -6,7 +8,9 @@
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
use serde_json::{Value, json};
use wordcraft_doc::Document;
use wordcraft_fonts::{FontDb, UserFont};
use wordcraft_layout::DocLayout;
use crate::cmd::insert::base64_decode;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -19,6 +23,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
CommandSpec::new("font.missing", "Missing Fonts", "Status Bar", missing)
.params(r#"{} → {"missing": [{"font": string, "shownAs": [string] (the families drawn instead), "metricCompatible": bool (its substitute measures like the font)}]} — the text the current view shows"#)
.pure(),
]
}
@@ -117,6 +124,102 @@ fn remove(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
}
/// A font the document's text asks for that isn't installed.
#[derive(Clone, Debug, PartialEq)]
pub struct MissingFont {
/// The name the document asks for.
pub font: String,
/// The families its text is drawn in instead: the substitute, and fallback faces for
/// characters the substitute lacks.
pub shown_as: Vec<String>,
/// The font's substitute measures like it (Carlito for Calibri…): line breaks don't move.
/// Fallback faces for characters the substitute lacks don't count: the font itself might
/// lack them too (an emoji in Calibri text).
pub metric_compatible: bool,
}
/// The family text set in `font` is drawn in when `font` isn't installed (None when it is), as
/// layout picks it for `doc`.
pub fn shown_as(doc: &Document, font: &str) -> Option<String> {
wordcraft_layout::para::substitute_face(doc, font).map(|f| f.get().family.clone())
}
/// The family Chinese, Japanese and Korean text is drawn in when its East Asian font `font_ea`
/// isn't installed (None when it is), the run's font being `font`, as layout picks it for `doc`.
pub fn east_asian_shown_as(doc: &Document, font_ea: &str, font: &str) -> Option<String> {
wordcraft_layout::para::east_asian_substitute_face(doc, font_ea, font).map(|f| f.get().family.clone())
}
/// Whether text in `font` drawn in `substitute` is worth a warning: the substitute measures
/// differently, so line breaks can move, and the font isn't a symbol font (drawn as its
/// Unicode equivalents on purpose, which layout leaves out of [`missing_fonts`] too). Decides
/// [`MissingFont::metric_compatible`], which the font box and the status bar both read.
pub fn warns(font: &str, substitute: &str) -> bool {
!wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::metric_compatible(font, substitute)
}
/// The fonts `doc`'s laid-out text asks for that aren't installed, from what layout drew
/// ([`DocLayout::substituted_fonts`]): only text the current view shows counts (no headers or
/// footers in Web, Draft or Read view), in every story on its pages, list labels and field
/// results included. Names differing only in case are one font.
pub fn missing_fonts(doc: &Document, layout: &DocLayout) -> Vec<MissingFont> {
// By key: in name order, case variants merged.
let mut out: std::collections::BTreeMap<String, MissingFont> = std::collections::BTreeMap::new();
for (recorded, face) in layout.substituted_fonts() {
let family = face.get().family.clone();
let font = shown_name(&recorded);
let m = out.entry(font_key(font)).or_insert_with(|| MissingFont {
font: font.to_string(),
shown_as: Vec::new(),
// Judged by the name layout looked up, spaces and all: "Arial " isn't Arial to it, so
// its text is in a substitute even where Arial is installed.
metric_compatible: shown_as(doc, &recorded).is_some_and(|s| !warns(font, &s)),
});
if !m.shown_as.contains(&family) {
m.shown_as.push(family);
}
}
out.into_values().collect()
}
/// A font name as [`missing_fonts`] lists it: as layout records it (at most 256 characters),
/// trimmed.
fn shown_name(font: &str) -> &str {
wordcraft_layout::para::reported_font_name(font).trim()
}
/// The key a font name is listed under in [`missing_fonts`]: two names with the same key are
/// one font. Look a document's font up in the list through this.
pub fn font_key(font: &str) -> String {
shown_name(font).to_lowercase()
}
/// The status line's form of a font name: a hostile file's names are long.
fn short_name(font: &str) -> String {
match font.char_indices().nth(40) {
Some((i, _)) => format!("{}…", font.get(..i).unwrap_or(font)),
None => font.to_string(),
}
}
fn missing(s: &mut Session, _: &Value) -> CmdResult {
let layout = s.layout();
let found = missing_fonts(&s.doc, &layout);
// From command search there's no dialog: the status line says what was found.
// A hostile file can name thousands of fonts: name the first few.
const NAMED: usize = 5;
let changing: Vec<&str> = found.iter().filter(|f| !f.metric_compatible).map(|f| f.font.as_str()).collect();
let named = changing.iter().take(NAMED).map(|f| short_name(f)).collect::<Vec<_>>().join(", ");
s.status = match (found.is_empty(), changing.len()) {
(true, _) => "Every font the document shows is installed".into(),
(false, 0) => "Fonts that aren't installed are shown in fonts that measure the same: line breaks don't move".into(),
(_, n) if n > NAMED => format!("Not installed: {named} and {} more", n - NAMED),
_ => format!("Not installed: {named}"),
};
let fonts: Vec<Value> = found.iter().map(|f| json!({"font": f.font, "shownAs": f.shown_as, "metricCompatible": f.metric_compatible})).collect();
Ok(json!({"missing": fonts}))
}
#[cfg(test)]
mod tests {
use super::*;
+19 -1
View File
@@ -138,6 +138,17 @@ pub fn specs() -> Vec<CommandSpec> {
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
// Font › Emphasis mark: a mark over (or under) each character.
CommandSpec::new("format.emphasisMark", "Emphasis Mark", "Home › Font › Font", |s, v| {
use wordcraft_doc::props::EmphasisMark;
let name = p::str(v, "mark").unwrap_or("dot");
let mark = EmphasisMark::ALL
.into_iter()
.find(|m| m.ooxml().eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| CmdError::Params(format!("unknown emphasis mark `{name}` (none, dot, comma, circle, underDot)")))?;
apply(s, &|c| c.emphasis = Some(mark))
})
.params(r#"{"mark"?: "none" | "dot" | "comma" | "circle" | "underDot"}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
@@ -866,8 +877,14 @@ pub fn state(s: &Session) -> Value {
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
let font = same(&|r| json!(r.font));
// The font's substitute when it isn't installed: the face its text is drawn in, apart from
// characters the substitute lacks. `font.missing` lists every face the shown text is drawn
// in, fallback faces too, and which fonts change the layout.
let shown_as = font.as_str().and_then(|f| crate::cmd::font::shown_as(&s.doc, f));
json!({
"font": same(&|r| json!(r.font)),
"font": font,
"fontShownAs": shown_as,
"fontEastAsia": same(&|r| json!(r.font_ea)),
"size": same(&|r| json!(r.size)),
"bold": all.iter().all(|r| r.bold),
@@ -882,6 +899,7 @@ pub fn state(s: &Session) -> Value {
"caps": all.iter().all(|r| r.caps),
"hidden": all.iter().all(|r| r.hidden),
"spacing": same(&|r| json!(r.spacing)),
"emphasisMark": same(&|r| json!(r.emphasis.ooxml())),
// Kerning for fonts: the smallest kerned size, points; 0 = off.
"kern": same(&|r| json!(r.kern)),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
+40 -3
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
use super::references::{FieldCode, leader};
use super::references::{FieldCode, field_arg, leader};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -44,11 +44,12 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
instr.push_str(&format!(" \\t \"{see}\""));
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
}
instr.push_str(&format!(" \\r {}", bm.trim()));
// A name read from a file may hold a space, a quote or a backslash.
instr.push_str(&format!(" \\r {}", field_arg(bm)));
}
if p::bool(v, "bold").unwrap_or(false) {
instr.push_str(" \\b");
@@ -264,6 +265,12 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
}) else {
return Ok(());
};
rebuild_from(s, i, leader, instr)
}
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
/// it, when the paragraph holds more than one).
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
let f = FieldCode::parse(&instr);
// Old entries go; keep their leader and the section their columns had.
let mut old_leader = None;
@@ -393,6 +400,36 @@ mod tests {
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
}
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
/// can hold) is quoted in the XE field, so it reads back whole.
#[test]
fn mark_entry_trims_and_quotes_its_bookmark() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
s.run("select.text", &json!({"text": "Intro"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
let odd = "My \"Mark\" \\b";
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
let mut instrs: Vec<String> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.filter_map(|o| match o {
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
_ => None,
})
.collect();
instrs.sort();
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
let beta = FieldCode::parse(&instrs[1]);
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
}
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
/// before right-aligned page numbers, and two columns in a continuous section.
+2 -2
View File
@@ -15,7 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+71 -16
View File
@@ -61,14 +61,16 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
let iso = crate::cmd::now_iso();
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts(fmt, y, m as usize, d, hms, days)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
@@ -83,6 +85,28 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
// text or out of it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
out.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
i += 1;
if q != '\'' {
out.push(q);
} else if chars.get(i) == Some(&'\'') {
out.push('\'');
i += 1;
} else {
break;
}
}
continue;
}
// `am/pm` (any case) is one token: AM or PM.
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().eq_ignore_ascii_case("am/pm")) {
out.push_str(if hh < 12 { "AM" } else { "PM" });
@@ -217,12 +241,16 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -230,6 +258,21 @@ pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
None
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
@@ -237,15 +280,27 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines_given = lines.is_some();
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
})
}
+12 -2
View File
@@ -137,7 +137,8 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let (mut w, mut h) = (natural_w, natural_h);
if let Some(want) = width {
let k = want.max(4.0) / w.max(1.0);
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
w *= k;
h *= k;
}
@@ -145,7 +146,16 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
h *= max_w / w;
w = max_w;
}
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
let obj = InlineObject::Image {
media: key.clone(),
w,
h,
alt: alt.to_string(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
// A selected Drawing Canvas takes it in.
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
return Ok(json!({"media": key, "canvasMember": i}));
+62
View File
@@ -1,5 +1,20 @@
use super::*;
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
/// writes as `null` and can't read back.
#[test]
fn a_huge_picture_width_keeps_the_size_finite() {
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
}
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
fn jpeg_strip_tiff() -> Vec<u8> {
let mut t = Vec::new();
@@ -334,6 +349,18 @@ fn symbol_takes_its_own_font() {
assert!(s.run("insert.symbol", &json!({"char": ""})).is_err());
}
#[test]
fn date_pictures_keep_quoted_text() {
// Text in single quotes is shown as it is (Word's date-time picture); two quotes are one.
assert_eq!(format_date_parts("H' o''clock'", 2026, 10, 21, (8, 0, 0), 0), "8 o'clock");
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
// An unclosed quote runs to the end.
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
}
#[test]
fn date_pictures() {
// Word accepts either case for the day and year instructions.
@@ -354,3 +381,38 @@ fn date_pictures() {
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
assert_eq!(date_picture("DATE"), None);
}
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
/// weekday, not UTC's.
#[test]
fn format_date_uses_the_local_offset() {
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
}
#[test]
fn local_civil_date_follows_the_utc_offset() {
let pic = "dddd d MMMM yyyy HH:mm";
let show = |unix: u64, off: i64| {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
format_date_parts(pic, y, m as usize, d, hms, days)
};
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
// Offset 0 is UTC.
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
// Near the epoch with a negative offset: the day before 1970, no overflow.
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
// Hostile values never panic.
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
let _ = crate::cmd::local_civil(0, i64::MIN);
// The machine's zone is a real offset (within ±26 h) and asking never panics.
for unix in [0, 1_793_401_200, u64::MAX] {
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
}
}
+7 -6
View File
@@ -4,6 +4,7 @@
//! the number and the number's font.
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::StoryRef;
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
@@ -183,26 +184,26 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
Some(n) => {
// Change the list in place: every list sharing its definition follows.
let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?;
if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) {
if let Some(a) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == aid) {
a.levels = levels.clone();
if name.is_some() {
a.name = name;
}
}
if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) {
if let Some(x) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == n) {
x.level_overrides.clear();
}
let sharing: Vec<u32> = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect();
touch_lists(s, &sharing)?;
n
}
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
None => Arc::make_mut(&mut s.doc.numbering).try_add_levels(name, levels.clone())?,
};
// Levels linked to a paragraph style number every paragraph in that style.
let mut linked = Vec::new();
for (i, l) in levels.iter().enumerate() {
let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue };
if let Some(st) = s.doc.styles.get_mut(&id) {
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
st.para.numbering = Some(NumRef { num, level: i as u8 });
linked.push(id);
}
@@ -292,11 +293,11 @@ fn preset_levels(preset: &str) -> Option<Vec<Level>> {
fn preset(s: &mut Session, v: &Value) -> CmdResult {
let id = p::req_str(v, "preset")?;
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
let num = s.doc.numbering.try_add_levels(None, levels)?;
let num = Arc::make_mut(&mut s.doc.numbering).try_add_levels(None, levels)?;
let mut linked = Vec::new();
for n in 1..=LEVELS as u8 {
let id = super::para::ensure_heading_style(s, n);
if let Some(st) = s.doc.styles.get_mut(&id) {
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
st.para.numbering = Some(NumRef { num, level: n - 1 });
linked.push(id);
}
+8 -11
View File
@@ -7,6 +7,7 @@ use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::section::{SectionProps, SectionStart};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
use super::references::{field_arg, field_words, quoted};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -122,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
let f = p::req_str(v, "field")?.to_string();
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
})
.params(r#"{"field": string}"#)
.key(super::non_mac("Alt+Shift+F")),
@@ -136,14 +137,14 @@ pub fn specs() -> Vec<CommandSpec> {
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
let instr = match rule.as_str() {
"IF" => format!(
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
quote(p::str(v, "field").unwrap_or("Field")),
p::str(v, "value").unwrap_or(""),
p::str(v, "then").unwrap_or(""),
p::str(v, "else").unwrap_or("")
"IF {{ MERGEFIELD {} }} = {} {} {}",
field_arg(p::str(v, "field").unwrap_or("Field")),
quoted(p::str(v, "value").unwrap_or("")),
quoted(p::str(v, "then").unwrap_or("")),
quoted(p::str(v, "else").unwrap_or(""))
),
"SKIPIF" | "NEXTIF" => {
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
}
"MERGEREC" => "MERGEREC".into(),
_ => "NEXT".into(),
@@ -227,10 +228,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn quote(f: &str) -> String {
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
}
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
let props = s.typing_props();
let at = delete_selection(s)?;
+8 -10
View File
@@ -1,6 +1,6 @@
use super::{
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
json, p, para_block,
field_words, json, p, para_block,
};
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
@@ -41,9 +41,7 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
return None;
}
let rest = rest.trim();
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
Some(name.to_string())
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
}
/// Which columns hold address parts.
@@ -103,17 +101,17 @@ fn rule_field(t: &str) -> Option<String> {
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
let field = rule_field(t).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or("");
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
let cmp = texts.next().unwrap_or_default();
let v = value(s, row, &field);
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
(Ok(a), Ok(b)) => a.partial_cmp(&b),
_ => Some(v.as_str().cmp(cmp)),
_ => Some(v.as_str().cmp(cmp.as_str())),
};
use std::cmp::Ordering::{Equal, Greater, Less};
let holds = match op.as_str() {
@@ -124,7 +122,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
">=" => matches!(ord, Some(Greater | Equal)),
_ => ord == Some(Equal),
};
(holds, quoted.collect())
(holds, texts.collect())
}
/// The first word of a field instruction, upper-cased.
@@ -149,7 +147,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
"IF" if rule_field(t).is_some() => {
let (holds, texts) = condition(s, t, row);
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
}
_ => return None,
})
+23
View File
@@ -307,6 +307,29 @@ fn if_rule_previews_its_result() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
}
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
#[test]
fn rule_texts_keep_their_quotes() {
let mut s = Session::new(Document::new());
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
s.run("mailings.rules", &rule).unwrap();
s.run("mailings.preview", &json!({"value": true})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
s.run("mailings.next", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
// SKIPIF compares with the value as typed.
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
}
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
/// own header.
#[test]
+1 -1
View File
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
pub(crate) use select::all_objects;
pub(crate) use select::{edit_obj, has_shape_or_canvas};
#[cfg(test)]
use select::{has_group, holds_ink, obj_size};
use select::{has_group, holds_ink, obj_size, with_obj};
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
#[cfg(test)]
use shape::can_link;
+48 -17
View File
@@ -1,5 +1,6 @@
use super::select::{obj_size, selected, with_obj};
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
use wordcraft_doc::picfx::PicFx;
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
@@ -148,13 +149,33 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
adjust_media(s, &media, |_| {}, f)
}
/// The selected picture's pre-edit media key (itself when never edited).
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
Ok(s.originals.get(&media).cloned().unwrap_or(media))
}
/// Drops brightness and contrast read from the file: an edit baked them from the original.
pub(super) fn clear_lum(fx: &mut PicFx) {
fx.bright = 0;
fx.contrast = 0;
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
/// [`PicFx`]) for an edit that bakes its own.
pub(super) fn adjust_media(
s: &mut Session,
base: &str,
fx: impl Fn(&mut PicFx),
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
@@ -179,8 +200,9 @@ pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::Dynami
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
*media = key.clone();
fx(pic_fx);
}
})
}
@@ -244,19 +266,25 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
});
}
let l = look.clone();
let out = adjust_media(s, &look.base, move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
})?;
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
let out = adjust_media(
s,
&look.base,
|_| {},
move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
},
)?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
{
@@ -299,10 +327,13 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
// brightness, contrast, transparency, #1711, #1766) included.
with_obj(s, |o| {
if let InlineObject::Image { media, crop, .. } = o {
if let InlineObject::Image { media, crop, fx, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
*fx = PicFx::default();
}
})
}
+17 -14
View File
@@ -1,12 +1,13 @@
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
use wordcraft_doc::picfx::PicFx;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -36,10 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
let base = original_media(s)?;
adjust_media(s, &base, clear_lum, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
@@ -62,10 +62,11 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
// Absolute from the original (like transparency): saturation 100 restores it.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
// Color and Corrections are independent, as in Word: only Washout, which Word stores
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
let base = original_media(s)?;
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -97,10 +98,10 @@ pub fn specs() -> Vec<CommandSpec> {
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
// into the pixels from here on.
let base = original_media(s)?;
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
@@ -309,8 +310,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
if let InlineObject::Shape { kind: k, .. } = o {
if let InlineObject::Shape { kind: k, extra, .. } = o {
*k = kind;
// A geometry kept from a file is no longer this shape's.
extra.odf_geometry = None;
}
})
})
+103
View File
@@ -1,4 +1,5 @@
use super::*;
use wordcraft_doc::picfx::PicFx;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
@@ -454,6 +455,108 @@ fn transparency_is_absolute_from_the_original() {
assert_eq!(rgba(&s), orig, "reset still finds the original");
}
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
/// file's included, as in Word.
#[test]
fn picture_edits_replace_the_file_corrections() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
let imported = PicFx::new(80_000, -80_000, 50_000);
let set_fx = |s: &mut Session, v: PicFx| {
with_obj(s, |o| {
if let InlineObject::Image { fx, .. } = o {
*fx = v;
}
})
.unwrap()
};
set_fx(&mut s, imported);
let state = |s: &Session| {
let (_, o) = selected(s).unwrap();
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
let bytes = s.doc.media.get(&media).cloned().unwrap();
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
};
let (orig_media, _, orig) = state(&s);
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
assert!(px.iter().all(|p| p[3] == 127));
// 0%: opaque, no `a:alphaModFix` left to write.
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx.transparency, px), (0, orig.clone()));
// Color other than Washout keeps the file's lum; Washout replaces it.
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
assert_eq!(state(&s).1, PicFx::default());
// Corrections bake from the original and replace the file's lum.
set_fx(&mut s, imported);
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
// Reset: the imported bitmap, no corrections at all.
set_fx(&mut s, imported);
s.run("picture.reset", &json!({})).unwrap();
let (media, fx, px) = state(&s);
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
}
/// Two pictures showing the same media, one with Washout from its file and one without:
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
/// corrections (it used to be able to pick up the other's).
#[test]
fn reset_never_borrows_another_pictures_corrections() {
let mut d = wordcraft_doc::Document::new();
let media = d.add_media(png(20, 10), "png");
let washout = PicFx::new(70_000, -70_000, 0);
let mut p = wordcraft_doc::Paragraph::new();
for fx in [PicFx::default(), washout] {
let pic = InlineObject::Image {
media: media.clone(),
w: 40.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx,
ole: None,
};
p.insert_object(0, pic, &Default::default()).unwrap();
}
d.body = vec![wordcraft_doc::para_block(p)];
let mut s = Session::new(d);
let pick = |s: &mut Session, at: usize| {
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
};
let fx_at = |s: &mut Session, at: usize| {
pick(s, at);
match selected(s).unwrap().1 {
InlineObject::Image { fx, media, .. } => (fx, media),
o => panic!("{o:?}"),
}
};
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
for at in [0, 3] {
pick(&mut s, at);
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
}
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
pick(&mut s, 3);
s.run("picture.reset", &json!({})).unwrap();
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
}
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
/// only the simple frame), a border replaces the border and keeps the style, an artistic
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
+2 -1
View File
@@ -1,6 +1,7 @@
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::collate::{SortKey, sort_key};
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
@@ -256,7 +257,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
let id = style_id(s, p::req_str(v, "style")?)?;
let on = p::bool(v, "value").unwrap_or(true);
if let Some(st) = s.doc.styles.get_mut(&id) {
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
st.quick = on;
}
sel_result(s)
+4 -3
View File
@@ -1,5 +1,6 @@
use super::format::fmt;
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
use std::sync::Arc;
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
@@ -45,9 +46,9 @@ pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
None => match kind {
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
Some(n) => n,
None => s.doc.numbering.try_add_list(kind)?,
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
},
_ => s.doc.numbering.try_add_list(kind)?,
_ => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
},
};
fmt(s, &|p| {
@@ -68,7 +69,7 @@ pub(super) fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
return Err(CmdError::Failed("not in a numbered list".into()));
};
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
// This paragraph and the following ones of the same list use the new instance.
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
for path in paths {
+2 -1
View File
@@ -1,5 +1,6 @@
use super::lists::touch_all;
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
use std::sync::Arc;
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
/// touches swap places with the paragraph above or below, in their own container (the story or
@@ -96,7 +97,7 @@ pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
if s.doc.styles.get(&sid).is_none()
&& let Some(st) = builtin.get(&sid).cloned()
{
s.doc.styles.upsert(st);
Arc::make_mut(&mut s.doc.styles).upsert(st);
}
}
touch_all(s);
+6 -5
View File
@@ -1,6 +1,7 @@
use super::format::fmt;
use super::lists::touch_all;
use super::{CharProps, CmdError, CmdResult, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
use std::sync::Arc;
pub(super) fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
@@ -101,7 +102,7 @@ pub(super) fn create_style(s: &mut Session, v: &Value) -> CmdResult {
st.chr.fmt_change = None;
st.para = st.para.formatting();
}
s.doc.styles.upsert(st);
Arc::make_mut(&mut s.doc.styles).upsert(st);
apply_style(s, &json!({"style": id}))?;
Ok(json!({"id": id}))
}
@@ -113,7 +114,7 @@ pub(super) fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
let next = p::str(v, "next").map(str::to_string);
let name = p::str(v, "name").map(str::to_string);
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
if let Some(c) = chr {
st.chr.overlay(&c);
}
@@ -175,7 +176,7 @@ pub(super) fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
};
let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?;
let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() };
if let Some(st) = s.doc.styles.get_mut(&id) {
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
st.chr = chr;
st.para = pp;
}
@@ -263,7 +264,7 @@ pub(super) fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
}
let mut changed = 0;
for g in &gone {
s.doc.styles.remove(&g.id);
Arc::make_mut(&mut s.doc.styles).remove(&g.id);
// Fall back to the base only while it is a style of the same kind that still exists.
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
changed += s.doc.restyle(&g.id, base);
@@ -279,7 +280,7 @@ pub(super) fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
if gallery.is_none() && hidden.is_none() {
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
}
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
if let Some(h) = hidden {
st.hidden = h;
}
+2 -1
View File
@@ -9,6 +9,7 @@
use serde_json::{Value, json};
use std::collections::HashSet;
use std::sync::Arc;
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::props::{CellProps, CharProps, RowProps, TableProps};
use wordcraft_doc::{Block, Blocks, Document, Path, PermRange, Pos, StoryRef};
@@ -78,7 +79,7 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
}
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
for st in &mut s.doc.styles.styles {
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
}
}
+2 -2
View File
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
#[test]
fn formatting_restriction_refuses_attach_template_update() {
let mut t = Document::from_text("Template.");
if let Some(st) = t.styles.styles.iter_mut().find(|st| st.id == "Heading1") {
if let Some(st) = Arc::make_mut(&mut t.styles).styles.iter_mut().find(|st| st.id == "Heading1") {
st.chr.size = Some(31.0);
}
let data = crate::cmd::insert::base64_encode(&crate::io::save_bytes("t.docx", &t).unwrap());
@@ -147,7 +147,7 @@ fn formatting_restriction_refuses_attach_template_update() {
assert!(e.contains("formatting is limited"), "{e}");
assert_eq!(s.doc, before);
// Copying styles in replaces their definitions, locked flag included (#3692).
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
let before = s.doc.clone();
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
assert!(e.contains("formatting is limited"), "{e}");

Some files were not shown because too many files have changed in this diff Show More