Compare commits

...
Author SHA1 Message Date
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
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
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
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
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
.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
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
.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
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
Dylan M. Taylor 08afab50b6 Merge pull request #5524 from storytold/rel-0.15.1
Release: WordCraft v0.15.1
2026-11-03 22:15:22 -05:00
Dylan M. Taylor cd6b497c8a Release: WordCraft v0.15.1
Bump the workspace version for the point release carrying the CP4.3
proofing work: one language list, the missing-pack bar on a fresh
install, and the Options › Language pane.
2026-11-03 21:59:44 -05:00
Dylan M. Taylor 1340705fa6 Merge pull request #5515 from storytold/fix/options-language
CP4.3: Options › Language: Your languages with proofing status, Add a language…, Set as default
2026-11-03 21:59:26 -05:00
Dylan M. Taylor 98da959ceb Merge pull request #5514 from storytold/fix/missing-pack-bar
CP4.3: the missing-pack bar works on a fresh install and counts the caret's language
2026-11-03 21:54:20 -05:00
185 changed files with 6301 additions and 1941 deletions
Generated
+20 -19
View File
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"arboard",
"chrono",
@@ -6820,7 +6820,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"serde_json",
"sys-locale",
@@ -6833,11 +6833,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.0"
version = "0.15.1"
[[package]]
name = "wordcraft-doc"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6851,7 +6851,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6868,7 +6868,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6892,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"cfb",
"chrono",
@@ -6925,7 +6925,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6939,7 +6939,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"cfb",
"chardetng",
@@ -6958,7 +6958,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"image",
"serde",
@@ -6966,7 +6966,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"craft-proof",
"image",
@@ -6986,7 +6986,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"log",
"serde_json",
@@ -6998,7 +6998,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7006,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7027,7 +7027,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"image",
"log",
@@ -7041,8 +7041,9 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"accesskit_consumer",
"cfb",
"chrono",
"craft-i18n",
@@ -7073,7 +7074,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"eframe",
"egui",
@@ -7092,7 +7093,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"log",
"proptest",
@@ -7255,7 +7256,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.15.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.15.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+18 -1
View File
File diff suppressed because one or more lines are too long
+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 -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(),
+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}");
}
+9 -5
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.
@@ -98,8 +98,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,
+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"] {
+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 });
+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());
+5 -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,
@@ -722,85 +724,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`
+37 -30
View File
@@ -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);
}
@@ -282,7 +286,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 {
@@ -361,7 +365,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 +480,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 +539,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 +645,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 +682,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 +787,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) {
+2 -2
View File
@@ -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 -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 ----
+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(())
}
+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)]);
}
+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();
+64 -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.
+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 -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}");
}
+12 -12
View File
@@ -248,7 +248,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 +265,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 +274,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();
@@ -567,7 +567,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 +620,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 {
@@ -1708,7 +1708,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()))]);
@@ -1940,10 +1940,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();
+1 -1
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()))]);
+2 -1
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
@@ -222,7 +223,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,
+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);
+4 -3
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),
+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::*;
+7 -1
View File
@@ -866,8 +866,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),
+34 -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");
@@ -393,6 +394,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 -1
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;
}
+15
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();
+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]
+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}");
+56 -30
View File
@@ -16,7 +16,9 @@ mod properties;
mod toc;
use fields::caption;
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
pub use fields::{
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq,
};
pub(super) use notes::note_options;
use notes::{note, note_refs};
pub use properties::{bookmark_text, date_picture};
@@ -110,37 +112,61 @@ pub struct FieldCode {
pub switches: Vec<(char, Option<String>)>,
}
/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]).
pub fn field_words(instr: &str) -> Vec<(String, bool)> {
let mut words: Vec<(String, bool)> = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut w = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
words.push((w, true));
} else {
let mut w = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
break;
}
w.push(c);
chars.next();
}
words.push((w, false));
}
}
words
}
/// `s` as quoted field-code text, which [`field_words`] reads back as it was.
pub fn quoted(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
o.push('"');
for c in s.chars() {
if matches!(c, '"' | '\\') {
o.push('\\');
}
o.push(c);
}
o.push('"');
o
}
/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`].
pub fn field_arg(s: &str) -> String {
if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() }
}
impl FieldCode {
pub fn parse(instr: &str) -> FieldCode {
let mut words: Vec<(String, bool)> = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut w = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
words.push((w, true));
} else {
let mut w = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
break;
}
w.push(c);
chars.next();
}
words.push((w, false));
}
}
let mut words = words.into_iter().peekable();
let mut words = field_words(instr).into_iter().peekable();
let mut f =
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
while let Some((w, quoted)) = words.next() {
+198 -44
View File
@@ -1,5 +1,6 @@
use super::properties::{
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
set_summary_property, summary_property, text_result,
};
use super::toc::{heading_style_level, update_toc};
use super::{
@@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
/// text or number of the nearest paragraph of a style.
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
let mut headings = [0u32; 9];
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
// Built on the first STYLEREF (field results don't change what it finds).
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
let numbers = seq_results(&s.doc);
apply_results(&mut s.doc, numbers)?;
let refs = styleref_results(&s.doc);
apply_results(&mut s.doc, refs)
}
/// A field's fresh result: its story and paragraph, its object index there and the text.
type FieldResult = (StoryRef, Path, usize, String);
/// The SEQ numbering so far while the document is walked in order.
#[derive(Default)]
struct SeqCount {
/// How many headings of each level there have been (for `\s`).
headings: [u32; 9],
/// Per sequence: its number, and the heading count it was last restarted at.
seq: std::collections::HashMap<String, (u32, u32)>,
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
boxes: std::collections::HashSet<u32>,
out: Vec<FieldResult>,
}
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
if let Some(l) = heading_style_level(p)
&& let Some(k) = (l as usize).checked_sub(1)
&& let Some(count) = headings.get_mut(k)
&& let Some(n) = count.headings.get_mut(k)
{
*count = count.saturating_add(1);
*n = n.saturating_add(1);
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
match f.name.as_str() {
"SEQ" => {
let Some(id) = f.args.first() else { continue };
let restart =
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
let n = seq.entry(id.clone()).or_insert((0, 0));
if let Some(epoch) = restart
&& n.1 != epoch
{
*n = (0, epoch);
}
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
n.0 = r;
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
}
"STYLEREF" => {
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
count_seq(doc, StoryRef::Body, &path, p, &mut count);
}
count.out
}
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
for (k, o) in p.objects.iter().enumerate() {
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
for bp in doc.para_paths(part) {
if let Some(boxed) = doc.para(part, &bp) {
count_seq(doc, part, &bp, boxed, count);
}
}
_ => {}
}
}
if updates.is_empty() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" {
continue;
}
let para = s.doc.para_mut(StoryRef::Body, &path)?;
for (k, r) in updates {
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
*result = r;
*dirty = false;
let Some(id) = f.args.first() else { continue };
let restart = f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied();
let n = count.seq.entry(id.clone()).or_insert((0, 0));
if let Some(epoch) = restart
&& n.1 != epoch
{
*n = (0, epoch);
}
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
n.0 = r;
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
}
}
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut out = Vec::new();
// Built on the first STYLEREF.
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
if FieldCode::parse(instr).name != "STYLEREF" {
continue;
}
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
if let Some(i) = ix.index_of(&path) {
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
}
}
}
out
}
/// What Word prints for a `REF` to a bookmark the document doesn't have.
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
/// fields refer to it.
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut out = Vec::new();
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
// The first of two bookmarks with one name, as `bookmark_text` takes.
starts.entry(name).or_insert(start);
}
// A bookmark's text, `None` when it ends in a later paragraph.
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
// here: those keep their stored result rather than print the paragraph's text.
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
continue;
}
let f = FieldCode::parse(instr);
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
let Some(start) = starts.get(target) else {
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
continue;
};
let text = texts.entry(target.to_string()).or_insert_with(|| {
let source = if start.story == StoryRef::Body { doc } else { stored };
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
});
if let Some(r) = text.clone() {
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
}
}
}
out
}
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
/// order.
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
let mut out = Vec::new();
for id in doc.parts.keys() {
let story = StoryRef::Part(*id);
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
for (off, o) in p.objects_with_offsets() {
if let InlineObject::BookmarkStart { name } = o {
out.push((name.clone(), Pos { story, path: path.clone(), off }));
}
}
}
}
out
}
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
/// Word's F9 clears it too).
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
for (story, path, k, r) in results {
let para = doc.para_mut(story, &path)?;
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
*result = r;
*dirty = false;
}
para.touch();
}
Ok(())
}
/// `results` less those its fields already show.
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
results.retain(|(story, path, k, r)| {
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
});
results
}
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
/// fields whose stored results were wrong). The document itself keeps its stored results.
/// `None` when every such field already shows its fresh result, so nothing is copied then.
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
// and a REF to a bookmarked caption, show the new number.
let seq = changed(doc, seq_results(doc));
let mut out = (!seq.is_empty()).then(|| doc.clone());
if let Some(d) = out.as_mut() {
apply_results(d, seq).ok()?;
}
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
if !styles.is_empty() {
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
}
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
if !refs.is_empty() {
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
}
out
}
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
fn has_switch(instr: &str, sw: char) -> bool {
instr.split('\\').skip(1).any(|s| {
+16 -1
View File
@@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option<String> {
/// the paragraph's end. `None` when there is no such bookmark.
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
bookmark_text_at(doc, name, &start)
}
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
doc.para(start.story, &start.path).is_some_and(|para| {
para.object_offsets()
.into_iter()
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
})
}
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
/// looked the document's bookmarks up once.
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
let para = doc.para(start.story, &start.path)?;
let end = para
.object_offsets()
.into_iter()
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
.unwrap_or(para.len());
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
}
+350
View File
@@ -511,3 +511,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
/// editor and the document keep every stored result.
#[test]
fn output_shows_fresh_seq_styleref_and_ref_results() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.run("select.text", &json!({"text": "Target words"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for (instr, result) in [
(r#"SEQ Figure \* ARABIC"#, "7"),
(r#"STYLEREF "Heading 1""#, "stale style"),
("REF Mark \\h", "stale text"),
("NUMWORDS", "999"),
(r#"SEQ Table \* ARABIC"#, "9"),
] {
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
// Stale stored results (inserting a field computes its result), the last field locked.
let stored = ["7", "stale style", "stale text", "999", "9"];
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
let mut stale = stored.iter();
for o in &mut para.objects {
if let InlineObject::Field { result, locked, .. } = o {
*result = stale.next().unwrap().to_string();
*locked = stale.len() == 0;
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages
.iter()
.flat_map(|pg| &pg.items)
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
let pdf = s.print_pdf(&json!({})).unwrap().0;
assert!(pdf.len() > 1000);
// Once the fields are current, output copies nothing.
update_seq(&mut s).unwrap();
update_fields(&mut s).unwrap();
assert!(output_fields(&s.doc).is_none());
}
#[test]
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
// and a REF to a bookmark in a box shows the box's text as stored.
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
let field = |instr: &str, stored: &str| {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
run(stored)
)
};
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
let text_box = |inner: String| {
format!(
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
)
};
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
let paras = [
seq("A"),
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
seq("D"),
format!("{}{}", seq("E"), text_box(seq("F"))),
seq("G"),
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
];
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
let root = r#"<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 mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
// The SEQ and REF results in reading order, text boxes where they are anchored.
let mut results = Vec::new();
out.objects_in_reading_order(&mut |o| {
if let InlineObject::Field { instr, result, .. } = o {
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
}
});
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
assert_eq!(refs, ["B Fig 9"], "{results:?}");
}
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
/// REF stored as "stale"), rather than Word's missing-bookmark error.
#[test]
fn output_shows_references_to_bookmarks_outside_the_body() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let mark = |name: &str, id: u32| {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
};
let field = |name: &str| {
format!(
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
)
};
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
let refs: String = names.iter().map(|n| field(n)).collect();
let document = format!(
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
);
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
let footnotes =
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
);
let root = r#"<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 files = [
("_rels/.rels", root.to_string()),
("word/_rels/document.xml.rels", rels),
("word/document.xml", document),
("word/header1.xml", header),
("word/footer1.xml", footer),
("word/footnotes.xml", footnotes),
("word/endnotes.xml", endnotes),
("word/comments.xml", comments),
];
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in &files {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
let mut results = Vec::new();
for path in out.para_paths(StoryRef::Body) {
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { result, .. } = o {
results.push(result.clone());
}
}
}
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
assert_eq!(results, expected);
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
#[test]
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages[0]
.items
.iter()
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
}
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
/// text; a plain REF to the same bookmark is filled in.
#[test]
fn output_keeps_paragraph_number_references_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
for instr in instrs {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "2.1".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the plain REF changes");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
}
/// The results of the fields in the paragraph at `path` of `d`.
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
d.para(StoryRef::Body, path)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect()
}
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = stale.into();
}
}
para.touch();
last
}
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
/// rather than show the first paragraph's part alone.
#[test]
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF Span \\h", "REF Short \\h"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = stale_last_paragraph(&mut s, "stored");
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
}
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
/// in output and after F9: the numbers are filled in before the style references read them.
#[test]
fn a_style_reference_reads_the_headings_new_sequence_number() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
// Stale stored results: the heading's number 9, the reference "stale".
let heading = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "9".into();
}
}
para.touch();
let last = stale_last_paragraph(&mut s, "stale");
let out = output_fields(&s.doc).expect("the fields change");
assert_eq!(results_in(&out, &heading), ["1"]);
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
update_seq(&mut s).unwrap();
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
}
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
/// `bookmark_text` (F9's lookup) gives, or Word's error.
#[test]
fn output_gives_every_reference_its_bookmarks_text() {
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
for (text, name) in [("First target", "A"), ("Second target", "B")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("insert.bookmark", &json!({"name": name})).unwrap();
}
s.run("caret.docEnd", &json!({})).unwrap();
let targets = ["A", "B", "A", "Missing", "A"];
for t in targets {
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the stale results change");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
assert_eq!(results, expected);
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
}
+5 -5
View File
@@ -112,7 +112,7 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_new(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
Arc::make_mut(&mut s.doc.styles).upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(true) {
apply(s, &id)?;
@@ -131,7 +131,7 @@ fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_modified(s, v)?;
let id = st.id.clone();
s.doc.styles.upsert(st);
Arc::make_mut(&mut s.doc.styles).upsert(st);
s.dirty = true;
if p::bool(v, "apply").unwrap_or(false) {
apply(s, &id)?;
@@ -189,7 +189,7 @@ pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
build_new(s, &v).ok()?
};
let id = st.id.clone();
let mut sheet = s.doc.styles.clone();
let mut sheet = (*s.doc.styles).clone();
sheet.upsert(st);
sheet.table_style(&id)
}
@@ -214,7 +214,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
}
let mut rebased = Vec::new();
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
for child in Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
let mut para = st.para.clone();
para.overlay(&child.para);
child.para = para;
@@ -231,7 +231,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
child.based_on = st.based_on.clone();
rebased.push(child.id.clone());
}
s.doc.styles.styles.retain(|x| x.id != id);
Arc::make_mut(&mut s.doc.styles).styles.retain(|x| x.id != id);
let fallback = table_style_id(s, "TableGrid");
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
for part in s.doc.parts.values_mut() {
+13 -12
View File
@@ -9,6 +9,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::Document;
@@ -100,8 +101,8 @@ fn update_styles(s: &mut Session, v: &Value) -> CmdResult {
/// What [`update_styles`] does to a document: all of `src`'s styles and its defaults.
pub fn apply_template_styles(doc: &mut Document, src: &Document) -> Value {
doc.styles.default_chr = src.styles.default_chr.clone();
doc.styles.default_para = src.styles.default_para.clone();
Arc::make_mut(&mut doc.styles).default_chr = src.styles.default_chr.clone();
Arc::make_mut(&mut doc.styles).default_para = src.styles.default_para.clone();
let ids: Vec<String> = src.styles.styles.iter().map(|st| st.id.clone()).collect();
copy_styles(doc, src, &ids)
}
@@ -172,7 +173,7 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
if s.doc.styles.styles.iter().any(|o| o.id != st.id && o.name.eq_ignore_ascii_case(&name)) {
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
}
if let Some(x) = s.doc.styles.get_mut(&st.id) {
if let Some(x) = Arc::make_mut(&mut s.doc.styles).get_mut(&st.id) {
x.name = name.clone();
}
touch_all(s);
@@ -346,7 +347,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
}
};
let builtin = doc.styles.get(&target).is_some_and(|d| d.builtin) || st.builtin;
doc.styles.upsert(Style { id: target.clone(), builtin, ..st.clone() });
Arc::make_mut(&mut doc.styles).upsert(Style { id: target.clone(), builtin, ..st.clone() });
map.insert(st.id.clone(), target.clone());
order.push((st.id.clone(), target));
}
@@ -366,7 +367,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
}
n
});
if let Some(d) = doc.styles.get_mut(&target) {
if let Some(d) = Arc::make_mut(&mut doc.styles).get_mut(&target) {
d.based_on = based_on;
d.next = next;
d.linked = linked;
@@ -389,8 +390,8 @@ fn import_num(doc: &mut Document, src: &Document, num: u32, map: &HashMap<String
for l in &mut levels {
l.style = l.style.as_deref().and_then(|s| map.get(s).cloned().or_else(|| doc.styles.get(s).map(|x| x.id.clone())));
}
doc.numbering.abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
doc.numbering.nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
Arc::make_mut(&mut doc.numbering).abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
Arc::make_mut(&mut doc.numbering).nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
done.insert(num, new_num);
Some(new_num)
}
@@ -405,17 +406,17 @@ mod tests {
/// "Box" (missing in the document) and a numbered "Steps" style.
fn template() -> Document {
let mut t = Document::new();
if let Some(h) = t.styles.get_mut("Heading1") {
if let Some(h) = Arc::make_mut(&mut t.styles).get_mut("Heading1") {
h.chr.size = Some(31.0);
}
t.styles.upsert(Style {
Arc::make_mut(&mut t.styles).upsert(Style {
id: "Box".into(),
name: "Box".into(),
kind: StyleKind::Paragraph,
based_on: Some("Normal".into()),
..Default::default()
});
t.styles.upsert(Style {
Arc::make_mut(&mut t.styles).upsert(Style {
id: "Callout".into(),
name: "Callout".into(),
kind: StyleKind::Paragraph,
@@ -423,8 +424,8 @@ mod tests {
chr: CharProps { italic: Some(true), ..Default::default() },
..Default::default()
});
let num = t.numbering.add_list(ListKind::Numbered);
t.styles.upsert(Style {
let num = Arc::make_mut(&mut t.numbering).add_list(ListKind::Numbered);
Arc::make_mut(&mut t.styles).upsert(Style {
id: "Steps".into(),
name: "Steps".into(),
kind: StyleKind::Paragraph,
+9 -19
View File
@@ -3,6 +3,7 @@ use super::{
Block, Border, BorderStyle, CmdError, CmdResult, ListKind, NumRef, OBJ, Path, Pos, Selection, Session, StoryRef, Table, Value, delete_selection,
p, sel_result, split_para, type_text_effect,
};
use std::sync::Arc;
/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes).
fn autoformat(s: &mut Session, text: &str) -> Option<String> {
@@ -164,7 +165,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
let num = match found {
Some(n) => n,
None => s.doc.numbering.try_add_list(kind)?,
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
};
let para = s.doc.para_mut(f.story, &f.path)?;
para.delete(0, f.off)?;
@@ -315,10 +316,11 @@ pub(super) fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
return None;
}
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
// Normal and outside a list, also one the style sheet gives (a numbered Normal style), with
// no outline level: resolving drops the body-text level 9 and above.
let plain = |p: &wordcraft_doc::Paragraph| {
let r = s.doc.styles.resolve_para(&p.props);
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none()
};
let text = prev.text.trim_start_matches('\t');
let tabs = prev.text.len() - text.len();
@@ -357,14 +359,8 @@ pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result
crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?;
// Removed tabs change the text, and the formatting comparison after the command only looks
// at paragraphs that keep theirs, so the style change is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
{
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &prev)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
p.touch();
if s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text) {
crate::cmd::fmt_revisions::record_para_props(s, at.story, &prev, &before.props)?;
}
s.sel = Selection::caret(at.clone());
Ok(())
@@ -398,14 +394,8 @@ pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Resu
p.touch();
// The split changes the block count, and the formatting comparison after the command
// skips a container whose block count changed, so the border is recorded here.
if s.doc.settings.track_changes
&& s.prefs.markup.track_formatting
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
{
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
let p = s.doc.para_mut(at.story, &at.path)?;
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
p.touch();
if let Some(before) = before {
crate::cmd::fmt_revisions::record_para_props(s, at.story, &at.path, &before)?;
}
s.sel = Selection::caret(new);
}
+2 -2
View File
@@ -30,9 +30,9 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
}
if p::bool(v, "default") == Some(true) {
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
s.doc.styles.default_chr.lang = Some(l.clone());
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some(l.clone());
// A Normal style that names its own language follows the new default.
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
if let Some(n) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "Normal")
&& n.chr.lang.is_some()
{
n.chr.lang = Some(l);
+5 -4
View File
@@ -8,6 +8,7 @@ use super::{
Block, CharProps, CmdError, CommandSpec, InlineObject, NumRef, Paragraph, Selection, StoryRef, Value, delete_selection, json, p, para_block,
sel_result,
};
use std::sync::Arc;
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
@@ -299,8 +300,8 @@ pub fn specs() -> Vec<CommandSpec> {
.and_then(|x| x.props.numbering)
.filter(|n| n.num != 0)
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
if let Some(num) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == new) {
num.start_overrides = vec![(n.level, value)];
}
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|q| *q >= f.path).collect();
@@ -323,9 +324,9 @@ pub fn specs() -> Vec<CommandSpec> {
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
let text = p::str(v, "text").unwrap_or("%1.").to_string();
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
let id = Arc::make_mut(&mut s.doc.numbering).try_add_list(wordcraft_doc::ListKind::Numbered)?;
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
&& let Some(abs) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|x| x.id == a)
&& let Some(l) = abs.levels.first_mut()
{
l.format = fmt;
+3 -2
View File
@@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> {
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
if read && !s.view.read_mode {
// Reading controls are session-only, and reset on each entry to the view.
// Width, size and spacing reset on each entry. Page Color follows the saved preference.
s.view.read_column_width = None;
s.view.read_zoom = 1.0;
s.view.read_line_spacing = None;
s.view.read_page_color = ReadPageColor::None;
s.view.read_page_color = s.prefs.read_page_color;
}
s.view.mode = m;
s.view.read_mode = read;
@@ -256,6 +256,7 @@ fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
};
s.view.read_page_color = color;
s.prefs.read_page_color = color;
Ok(json!({"pageColor": color}))
}
+5
View File
@@ -31,6 +31,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
// Output shows the fields Word fills in as it prints (see `output_fields`).
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
@@ -46,6 +49,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
/// markup area with the comments in balloons (page images always show tracked changes).
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
+31
View File
@@ -49,6 +49,9 @@ pub struct Prefs {
/// Check spelling as you type (File › Options › Proofing). The live value is
/// `ViewState::proofing`; this is the copy that is saved and restored (#1792).
pub check_spelling: bool,
/// Read Mode's Page Color. The live value is `ViewState::read_page_color`.
#[serde(deserialize_with = "lenient_read_page_color")]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub ignore_uppercase: bool,
pub ignore_numbers: bool,
pub ignore_internet: bool,
@@ -74,6 +77,7 @@ impl Default for Prefs {
marks: Default::default(),
mark_grammar: true,
check_spelling: true,
read_page_color: Default::default(),
ignore_uppercase: true,
ignore_numbers: true,
ignore_internet: true,
@@ -100,3 +104,30 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
let value = serde_json::Value::deserialize(d)?;
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::Prefs;
use crate::cmd::view::ReadPageColor;
#[test]
fn read_page_color_loads_from_old_and_damaged_prefs() {
let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap();
assert_eq!(old.read_page_color, ReadPageColor::None);
assert!(!old.check_spelling);
for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] {
let prefs: Prefs = serde_json::from_str(bad).unwrap();
assert_eq!(prefs.read_page_color, ReadPageColor::None);
assert!(!prefs.check_spelling);
}
let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap();
assert_eq!(saved.read_page_color, ReadPageColor::Sepia);
assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia");
}
}
+88 -5
View File
@@ -2,7 +2,8 @@
//!
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex
//! are handled by the host.
//!
//! Page ranges and copies come from users and control channels, so they are bounded: a range
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
@@ -10,6 +11,7 @@
use serde_json::{Value, json};
use wordcraft_layout::DocLayout;
pub use wordcraft_pdf::imposition::Imposition;
use crate::{CmdError, Session, p};
@@ -146,6 +148,47 @@ pub fn paper(v: &Value) -> Result<Option<(&'static str, f32, f32)>, CmdError> {
}
}
/// Validate the physical sheet layout without touching document page setup.
pub fn imposition(v: &Value) -> Result<wordcraft_pdf::imposition::Imposition, CmdError> {
match v.get("pagesPerSheet") {
None => Ok(Imposition::Single),
Some(Value::Number(n)) => match n.as_u64() {
Some(1) => Ok(Imposition::Single),
Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)),
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
},
Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }),
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
}
}
/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy.
fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result<Option<(f32, f32)>, CmdError> {
let paper = paper(v)?.map(|(_, w, h)| (w, h));
if imposition(v)? == Imposition::Single {
return Ok(paper);
}
let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h));
let Some((w, h)) = paper.or(own) else { return Ok(paper) };
match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") {
"auto" => Ok(paper),
"portrait" => Ok(Some((w.min(h), w.max(h)))),
"landscape" => Ok(Some((w.max(h), w.min(h)))),
_ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())),
}
}
/// Plan physical sheet sides for one copy, using the same selection and geometry as printing.
pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result<Vec<wordcraft_pdf::imposition::Sheet>, CmdError> {
if !v.is_object() {
return Err(CmdError::Params("print settings must be an object".into()));
}
let mut single = v.clone();
single["copies"] = json!(1);
let pages = plan(&single, lay.pages.len(), current)?.pages;
imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string()))
}
impl Session {
/// The pages the caret (or selection) is on, in the output layout: (first, last).
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
@@ -169,14 +212,26 @@ impl Session {
self.print_pdf_with(v, false)
}
/// Render a shared sheet plan for a saved PDF or an annotation-free printer job.
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
if !v.is_object() {
return Err(CmdError::Params("print settings must be an object".into()));
}
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
let out = crate::cmd::references::output_fields(&self.doc);
let doc = out.as_ref().unwrap_or(&self.doc);
let lay = self.export_layout_of(doc, markup.markup);
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
let paper = paper(v)?.map(|(_, w, h)| (w, h));
let mut single = v.clone();
single["copies"] = json!(1);
let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?;
let paper = imposed_paper(v, &lay, &single_plan.pages)?;
let opts = wordcraft_pdf::PdfOptions {
pages: Some(plan.pages.clone()),
pages: Some(single_plan.pages),
imposition: imposition(v)?,
sheet_copies: plan.copies,
sheet_collate: p::bool(v, "collate").unwrap_or(true),
// Repeated pages would repeat the structure tree's entries too.
tagged: plan.copies <= 1,
include_markup: markup.markup,
@@ -185,7 +240,7 @@ impl Session {
links,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
Ok((bytes, plan))
}
}
@@ -340,3 +395,31 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
}
#[cfg(test)]
mod imposition_tests {
use super::*;
/// Sheet printing leaves document bytes, dirty state, selection and undo history untouched.
#[test]
fn print_imposition_is_pure_and_rejects_hostile_modes() {
let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five"));
let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap());
for (mode, sides) in
[(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)]
{
let params = json!({"pagesPerSheet":mode, "copies":2});
let lay = s.export_layout();
assert_eq!(sheets(&params, &lay, s.current_pages(&lay)).unwrap().len(), sides);
let (bytes, plan) = s.print_pdf(&params).unwrap();
assert_eq!(plan.copies, 2);
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
}
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
}
for params in [json!(null), json!([]), json!(false)] {
assert!(s.print_pdf(&params).is_err());
}
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
//! Built-in sample documents and templates (original text, generated in code).
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor};
use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block};
@@ -50,10 +51,10 @@ pub fn sample_document() -> Document {
quote.props.space_before = Some(12.0);
b.push(Block::Para(quote));
b.push(Block::Para(styled("What we agree on", "Heading2")));
let mut num = d.numbering.clone();
let mut num = (*d.numbering).clone();
let bullets = num.add_list(ListKind::Bullet);
let steps = num.add_list(ListKind::Numbered);
d.numbering = num;
d.numbering = Arc::new(num);
for (lead, rest) in [
("Clean as you go. ", "Leave a table the way you would like to find it."),
("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."),
+2
View File
@@ -385,6 +385,7 @@ impl Session {
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
self.view.read_page_color = self.prefs.read_page_color;
self.others.push(d);
Ok(())
}
@@ -459,6 +460,7 @@ impl Session {
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
view.read_page_color = self.prefs.read_page_color;
let mut d = DocState::new(doc, untitled, view);
let id = d.document_id;
self.order.push(id);
+10 -3
View File
@@ -161,8 +161,15 @@ impl Session {
}
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
/// area with the comments in balloons.
/// area with the comments in balloons. Fields Word fills in for output show their fresh
/// results ([`crate::cmd::references::output_fields`]).
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
let out = crate::cmd::references::output_fields(&self.doc);
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
}
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
let (fn_name, fn_path) = self.resolved_filenames();
let opts = LayoutOptions {
view: ViewMode::Print,
@@ -172,7 +179,7 @@ impl Session {
filepath: Some(Arc::from(fn_path.as_str())),
..Default::default()
};
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
}
/// Invalidate the cached layout (fonts changed etc.).
@@ -199,7 +206,7 @@ impl Session {
self.editing_lang = tag;
let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty();
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
self.rev = self.rev.wrapping_add(1);
// Not an edit: the document is as untouched as it was.
if self.made_rev == self.rev.wrapping_sub(1) {
+1
View File
@@ -31,6 +31,7 @@ impl Session {
self.hf_return = None;
self.go_back_step = 0;
self.view.outline.collapsed.clear();
self.view.read_page_color = self.prefs.read_page_color;
// A copy the replaced document kept stays in the recovery list.
#[cfg(not(target_arch = "wasm32"))]
{
+1
View File
@@ -135,6 +135,7 @@ impl Session {
let current = document_id == self.document_id && rev == self.rev;
self.sel = sel;
self.view = view;
self.view.read_page_color = self.prefs.read_page_color;
self.pending = pending;
self.goal_x = goal_x;
self.page_hint = page_hint;
+244 -35
View File
@@ -4,6 +4,7 @@ use wordcraft_proof::cs::install_dictionary;
mod czech_test_dict;
use serde_json::json;
use std::sync::Arc;
use wordcraft_doc::props::BorderStyle;
use wordcraft_doc::{Pos, StoryRef};
@@ -2570,19 +2571,22 @@ fn select_extend_from_a_table_paragraph_selects_the_document() {
assert_eq!(b, s.doc.end_of(StoryRef::Body));
}
/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table
/// in the deepest cell says so; a table pasted there brings its text as paragraphs.
/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot)
/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text
/// as paragraphs.
#[test]
fn tables_nest_at_most_24_deep() {
fn tables_nest_only_as_deep_as_a_json_save_reads() {
use wordcraft_doc::table::MAX_TABLE_DEPTH;
let mut s = s();
for _ in 0..MAX_TABLE_DEPTH {
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
}
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
let before = s.doc.clone();
let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err();
assert!(e.to_string().contains("24 deep"), "{e}");
assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}");
assert_eq!(s.doc, before);
run(&mut s, "edit.paste", json!({"text": "b", "html": "<table><tr><td>b</td></tr></table>"}));
let cell = s.sel.focus.path.clone();
@@ -2591,6 +2595,44 @@ fn tables_nest_at_most_24_deep() {
assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there");
}
/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads
/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and
/// gradient text effects changed with Track Changes on (the change keeps the old effects too).
#[test]
fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() {
use wordcraft_doc::table::MAX_TABLE_DEPTH;
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}}));
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "text.insert", json!({"text": "word"}));
run(&mut s, "select.text", json!({"text": "word"}));
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}}));
run(&mut s, "review.trackChanges", json!({"on": true}));
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}}));
run(&mut s, "review.trackChanges", json!({"on": false}));
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "select.collapse", json!({"end": true}));
run(&mut s, "insert.textBox", json!({"text": "in box"}));
for _ in 0..MAX_TABLE_DEPTH {
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
}
assert_ne!(s.sel.focus.story, StoryRef::Body);
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
run(&mut s, "edit.paste", json!({}));
// Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did.
let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap();
let gradient = |f: &Option<wordcraft_doc::fill::Fill>| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_)));
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}");
assert!(
para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())),
"{para:?}"
);
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
}
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
#[test]
@@ -4356,7 +4398,7 @@ fn toc_page_numbers_reach_the_right_margin() {
let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line";
let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix"));
{
let toc2 = d.styles.get_mut("TOC2").unwrap();
let toc2 = Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap();
toc2.para.tabs = Some(vec![
TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None },
TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot },
@@ -4493,7 +4535,7 @@ fn toc_entries_carry_heading_numbers() {
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
}
let num = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let num = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Numbered);
for b in s.doc.body.iter_mut() {
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
&& p.props.style.as_deref() == Some("Heading1")
@@ -4593,13 +4635,41 @@ fn hostile_params_never_panic() {
for j in &junk {
let mut s = Session::new(crate::sample::sample_document());
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
let _ = s.run(spec.id, j);
let r = s.run(spec.id, j);
assert!(!caught_panic(&reg, &r), "{} panicked on {j}: {r:?}", spec.id);
s.clamp_selection();
let _ = s.layout();
}
}
}
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
/// those words.
fn caught_panic(reg: &crate::Registry, r: &Result<serde_json::Value, crate::CmdError>) -> bool {
matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some()))
}
#[test]
fn caught_panic_flags_a_command_that_panics() {
let mut s = Session::new(crate::sample::sample_document());
let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure();
// Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207).
let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure();
s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer]));
let boom = s.run("test.boom", &json!({}));
assert!(caught_panic(&s.registry, &boom));
let nested = s.run("test.outer", &json!({}));
assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}");
assert!(caught_panic(&s.registry, &nested));
assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into()))));
// A command's own error can quote what it was given.
let mut full = Session::new(crate::sample::sample_document());
let echoed = full.run("developer.control", &json!({"control": "x panicked: y"}));
assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}");
assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}");
}
#[test]
fn inspect_reports_structure() {
let mut s = Session::new(crate::sample::sample_document());
@@ -4766,10 +4836,10 @@ fn commands_after_the_largest_ids_take_free_ones() {
s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() });
s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() });
}
s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
s.doc.numbering.abstracts[0].id = u32::MAX;
s.doc.numbering.nums[0].id = u32::MAX;
s.doc.numbering.nums[0].abstract_id = u32::MAX;
Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
Arc::make_mut(&mut s.doc.numbering).abstracts[0].id = u32::MAX;
Arc::make_mut(&mut s.doc.numbering).nums[0].id = u32::MAX;
Arc::make_mut(&mut s.doc.numbering).nums[0].abstract_id = u32::MAX;
let before = s.doc.clone();
assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1}));
assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2}));
@@ -4866,7 +4936,7 @@ fn word_count_includes_text_boxes_by_default() {
#[test]
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
let mut d = wordcraft_doc::Document::new();
let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let num_id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default());
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 });
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))];
@@ -5839,6 +5909,111 @@ fn modify_style_takes_hex_colours_like_format_color() {
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}");
}
#[test]
fn undo_steps_share_the_style_sheet_and_lists_until_a_command_changes_them() {
// Every undo step copied the whole style sheet and list definitions: kilobytes a style, so
// typing into a document with hundreds of styles cost megabytes a step.
let mut s = Session::new(wordcraft_doc::Document::from_text("One two three."));
for _ in 0..5 {
run(&mut s, "text.insert", json!({"text": "word "}));
run(&mut s, "caret.left", json!({}));
}
assert_eq!(s.undo_labels().len(), 5, "five undo steps");
assert!(Arc::strong_count(&s.doc.styles) >= 6, "the document and each step hold one style sheet");
assert!(Arc::strong_count(&s.doc.numbering) >= 6, "and one set of list definitions");
// A command that changes a style copies the sheet once; undo puts the shared one back.
let sheet = Arc::clone(&s.doc.styles);
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"bold": true}}));
assert!(!Arc::ptr_eq(&sheet, &s.doc.styles));
assert_ne!(*sheet, *s.doc.styles, "the change is in the new copy only");
run(&mut s, "edit.undo", json!({}));
assert!(Arc::ptr_eq(&sheet, &s.doc.styles), "undo restores the step's own sheet");
// Lists likewise.
let lists = Arc::clone(&s.doc.numbering);
run(&mut s, "para.numbering", json!({}));
assert!(!Arc::ptr_eq(&lists, &s.doc.numbering));
assert!(s.doc.numbering.nums.len() > lists.nums.len());
run(&mut s, "edit.undo", json!({}));
assert!(Arc::ptr_eq(&lists, &s.doc.numbering));
}
/// The same at scale: 1,000 edits of a document with 600 styles and 60 lists of nine levels. The
/// history keeps the newest `MAX_UNDO` steps and drops the oldest. Its steps hold one style sheet
/// for each style change they span, however many steps there are, and one set of lists. A sheet
/// that only dropped steps held is freed.
#[test]
fn a_thousand_edits_of_a_large_document_keep_the_newest_steps_and_share_its_styles() {
use crate::doc_state::MAX_UNDO;
use std::collections::HashSet;
use std::sync::Weak;
let body: Vec<String> = (0..200).map(|i| format!("Paragraph {i} of the body, with a few words in it.")).collect();
let mut doc = wordcraft_doc::Document::from_text(&body.join("\n"));
let sheet = Arc::make_mut(&mut doc.styles);
let normal = sheet.get("Normal").cloned().unwrap();
for i in 0..600 {
let (id, name) = (format!("Custom{i}"), format!("Custom {i}"));
sheet.upsert(wordcraft_doc::Style {
id,
name,
based_on: Some("Normal".into()),
builtin: false,
default: false,
quick: false,
..normal.clone()
});
}
for _ in 0..60 {
Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Legal);
}
let mut s = Session::new(doc);
// An edit types a word, except every hundredth, which changes a style: one undo step each.
// After each edit: the text, and the sheet and lists the document then had.
let edits = 1000;
let mut after = Vec::with_capacity(edits);
let mut probe = Weak::new();
for i in 0..edits {
if i % 100 == 50 {
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"size": 10 + i / 100}}));
} else {
run(&mut s, "text.insert", json!({"text": format!("w{i} ")}));
run(&mut s, "caret.left", json!({}));
}
after.push((text(&s), Arc::downgrade(&s.doc.styles), Arc::downgrade(&s.doc.numbering)));
// Edit 50's sheet is replaced at edit 150, so the step taken before edit 150 is the last
// to hold it. That step is the oldest one kept until edit 650 pushes it out.
match i {
50 => probe = Arc::downgrade(&s.doc.styles),
649 => assert!(probe.upgrade().is_some(), "a kept step still holds edit 50's sheet"),
650 => assert!(probe.upgrade().is_none(), "dropping the last step that held edit 50's sheet frees it"),
_ => {}
}
}
assert_eq!(s.undo_steps_so_far(), edits as u64, "one undo step an edit");
assert_eq!(s.undo_depth(), MAX_UNDO);
// Undo goes back through the newest steps, newest first. Each restores the text and the very
// sheet and lists the document had after that edit: the steps share them, not copies.
let (mut sheets, mut lists) = (HashSet::new(), HashSet::new());
for k in 0..=MAX_UNDO {
let (txt, sheet, numbering) = &after[edits - 1 - k];
assert_eq!(&text(&s), txt, "after {k} undos");
assert!(Arc::ptr_eq(&s.doc.styles, &sheet.upgrade().unwrap()), "the sheet after {k} undos");
assert!(Arc::ptr_eq(&s.doc.numbering, &numbering.upgrade().unwrap()), "the lists after {k} undos");
sheets.insert(Arc::as_ptr(&s.doc.styles));
lists.insert(Arc::as_ptr(&s.doc.numbering));
if k < MAX_UNDO {
assert!(s.undo(), "undo {}", k + 1);
}
}
assert!(!s.undo(), "the oldest 500 steps were dropped: edits 0 to 499 can't be undone");
// The 501 states, the document's and the kept steps', hold six sheets (edit 450's, and those
// of the changes at 550 to 950) and one set of lists.
assert_eq!(sheets.len(), 6);
assert_eq!(lists.len(), 1);
}
/// #865: the other colours in CharProps and ParaProps (`underlineColor`, `shading`, a border's
/// `color`) take `RRGGBB` / `#RRGGBB` like `para.borders` and `table.shading`, and still `[r, g, b]`.
#[test]
@@ -6560,7 +6735,7 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
}
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
/// document alone, is not saved, and starts at None on each entry to Read Mode.
/// document alone, starts at None and is kept between documents and runs as a preference.
#[test]
fn reading_page_color_is_a_transient_view_setting() {
use crate::cmd::view::ReadPageColor;
@@ -6571,18 +6746,46 @@ fn reading_page_color_is_a_transient_view_setting() {
assert_eq!(s.view.read_page_color, ReadPageColor::None);
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
assert_eq!(s.prefs.read_page_color, ReadPageColor::Sepia);
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
}
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
assert_eq!(s.doc, original);
assert_eq!(s.dirty, dirty);
// The color stays for the next entry to Read Mode, while the other reading controls reset.
run(&mut s, "view.readTextSize", json!({"value": 150}));
run(&mut s, "view.printLayout", json!({}));
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::None);
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
assert_eq!(s.view.read_zoom, 1.0);
}
#[test]
fn reading_page_color_follows_saved_prefs_and_new_documents() {
use crate::cmd::view::ReadPageColor;
let mut s = s();
s.prefs.read_page_color = ReadPageColor::Sepia;
run(&mut s, "view.readMode", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
let first = s.document_id();
s.view.read_page_color = ReadPageColor::None;
run(&mut s, "file.new", json!({}));
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
s.activate(first).unwrap();
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
s.view.read_page_color = ReadPageColor::None;
run(&mut s, "file.open", json!({"path": "reading-color.docx", "data": ""}));
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
s.view.read_page_color = ReadPageColor::None;
s.set_document(wordcraft_doc::Document::new());
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
}
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
@@ -7088,7 +7291,7 @@ fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
for st in s.doc.styles.styles.iter_mut() {
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
st.builtin = false;
}
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
@@ -7233,7 +7436,7 @@ fn delete_table_style_falls_back_and_undoes() {
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
for st in s.doc.styles.styles.iter_mut() {
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
st.builtin = false;
}
for name in ["Table Grid", "Normal Table", "TableGrid"] {
@@ -7845,7 +8048,7 @@ fn list_buttons_follow_the_paragraphs_own_level() {
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
run(&mut s, "para.listLevel", json!({"level": 1}));
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
run(&mut s, "para.numbering", json!({}));
@@ -9232,7 +9435,7 @@ fn default_proofing_language_survives_a_restart() {
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
let mut english = wordcraft_doc::Document::from_text("English document");
english.styles.default_chr.lang = Some("en-GB".into());
Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into());
let bytes = crate::io::save_bytes("english.docx", &english).unwrap();
let data = crate::cmd::insert::base64_encode(&bytes);
run(&mut second, "file.open", json!({"path": "english.docx", "data": data}));
@@ -9875,7 +10078,7 @@ fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
let mut s = s();
s.set_editing_language(Some("pl-PL"));
s.doc.styles.default_chr.lang = None;
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "3,50"}));
run(&mut s, "text.tab", json!({}));
@@ -10538,8 +10741,14 @@ fn inspect_document_hidden_text_in_every_story_and_from_styles() {
run(&mut s, "insert.footer", json!({"text": "f"}));
s.sel = crate::Selection::caret(Pos::body(0, 0));
let hidden = CharProps { hidden: Some(true), ..Default::default() };
s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() });
s.doc.styles.upsert(Style {
Arc::make_mut(&mut s.doc.styles).upsert(Style {
id: "HideC".into(),
name: "Hide C".into(),
kind: StyleKind::Character,
chr: hidden.clone(),
..Default::default()
});
Arc::make_mut(&mut s.doc.styles).upsert(Style {
id: "HideP".into(),
name: "Hide P".into(),
kind: StyleKind::Paragraph,
@@ -10650,7 +10859,7 @@ fn inspect_document_removes_text_hidden_by_document_defaults() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Paragraph, para_block};
let mut s = s();
s.doc.styles.default_chr.hidden = Some(true);
Arc::make_mut(&mut s.doc.styles).default_chr.hidden = Some(true);
s.doc.body = vec![
para_block(Paragraph::with_text("hide-default", CharProps::default())),
para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })),
@@ -10693,7 +10902,7 @@ fn inspect_document_keeps_content_control_markers() {
fn inspect_document_lists_tables_whose_style_hides_text() {
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
let mut s = s();
s.doc.styles.upsert(Style {
Arc::make_mut(&mut s.doc.styles).upsert(Style {
id: "HideHeader".into(),
name: "Hide Header".into(),
kind: StyleKind::Table,
@@ -11287,8 +11496,8 @@ fn toc_keeps_direct_number_tabs() {
d.last_section.page_w = 220.0;
d.last_section.margin_left = 20.0;
d.last_section.margin_right = 20.0;
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
{
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.props.style = Some("TOCHeading".into());
@@ -11590,11 +11799,11 @@ fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
body.chr.size = Some(20.0);
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
s.doc.styles.styles.push(body.clone());
s.doc.styles.styles.push(grid.clone());
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
s.doc.styles.default_chr.lang = Some("de-DE".into());
s.doc.styles.default_chr.size = Some(11.0);
Arc::make_mut(&mut s.doc.styles).styles.push(body.clone());
Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone());
Arc::make_mut(&mut s.doc.styles).latent_styles = Some("<w:latentStyles/>".into());
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into());
Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0);
run(&mut s, "design.styleSet", json!({ "name": name }));
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
@@ -11664,8 +11873,8 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
let mut s = s();
s.doc.styles.default_chr.size = None;
s.doc.styles.default_chr.size_cs = Some(10.0);
Arc::make_mut(&mut s.doc.styles).default_chr.size = None;
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0);
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
ch.chr.size = Some(20.0);
ch.chr.size_cs = Some(22.0);
@@ -11674,7 +11883,7 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
parts.header_chr.size = Some(14.0);
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
tb.table = Some(parts);
s.doc.styles.styles.extend([ch, tb]);
Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]);
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
let st = &s.doc.styles;
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
@@ -11836,7 +12045,7 @@ fn alt_chunks_open_in_place_as_word_brings_them_in() {
fn text_from_file_brings_the_files_pictures() {
let dir = scratch_dir("text-from-file-pictures");
let mut src = Session::new(wordcraft_doc::Document::new());
let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet);
let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet);
// A list item, then a paragraph (a single paragraph would take the caret's formatting).
src.doc.split_paragraph(&Pos::body(0, 0)).unwrap();
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
@@ -11851,7 +12060,7 @@ fn text_from_file_brings_the_files_pictures() {
let mut s = s();
// A picture and a list of our own first, so the file's key and list id would point at ours.
let ours = s.doc.add_media(vec![9, 9, 9], "png");
let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let shown: Vec<String> = s
+4 -3
View File
@@ -2,6 +2,7 @@
//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open.
use serde_json::json;
use std::sync::Arc;
use wordcraft_doc::props::Align;
use wordcraft_doc::{Pos, StoryRef};
@@ -118,9 +119,9 @@ fn font_commands_override_inherited_complex_script_properties_explicitly() {
use wordcraft_doc::props::CharProps;
let mut s = session("שלום ABC", true);
s.doc.styles.default_chr.size_cs = Some(30.0);
s.doc.styles.default_chr.bold_cs = Some(true);
s.doc.styles.default_chr.italic_cs = Some(true);
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0);
Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true);
Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true);
run(&mut s, "select.all", json!({}));
run(&mut s, "format.bold", json!({"value": false}));
run(&mut s, "format.italic", json!({"value": false}));
+4 -3
View File
@@ -59,15 +59,16 @@ fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
use wordcraft_doc::styles::{Style, StyleKind};
let mut s = mixed();
s.doc.styles.default_chr.size_cs = Some(30.0);
s.doc.styles.upsert(Style {
let styles = std::sync::Arc::make_mut(&mut s.doc.styles);
styles.default_chr.size_cs = Some(30.0);
styles.upsert(Style {
id: "Large".into(),
name: "Large".into(),
based_on: Some("Normal".into()),
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
..Default::default()
});
s.doc.styles.upsert(Style {
styles.upsert(Style {
id: "Small".into(),
name: "Small".into(),
kind: StyleKind::Character,
+277
View File
@@ -192,3 +192,280 @@ fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() {
let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT));
assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})");
}
/// `font.missing`'s fonts, as "font>shownAs".
fn missing_list(s: &mut Session) -> Vec<String> {
let m = s.run("font.missing", &json!({})).unwrap();
m["missing"]
.as_array()
.unwrap()
.iter()
.map(|f| {
let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect();
format!("{}>{}", f["font"].as_str().unwrap(), shown.join(","))
})
.collect()
}
/// The missing fonts of `doc` as laid out.
fn missing_in(doc: wordcraft_doc::Document) -> Vec<crate::cmd::font::MissingFont> {
let mut s = Session::new(doc);
let layout = s.layout();
crate::cmd::font::missing_fonts(&s.doc, &layout)
}
/// A paragraph of `text` set in `font`.
fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph {
wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() })
}
/// A document of these paragraphs.
fn doc_of(paras: Vec<wordcraft_doc::Paragraph>) -> wordcraft_doc::Document {
let mut doc = wordcraft_doc::Document::new();
doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect();
doc
}
#[test]
fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() {
// A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute.
const MISSING: &str = "Wordcraft Nowhere Face";
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed");
assert_eq!(missing_list(&mut s), Vec::<String>::new());
s.run("select.text", &json!({"text": "Missing"})).unwrap();
s.run("format.font", &json!({"name": MISSING})).unwrap();
let st = s.run("format.state", &json!({})).unwrap();
assert_eq!(st["font"], MISSING);
let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string();
assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}");
assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once");
// A mixed selection has no single font, so nothing to flag in the font box.
s.run("select.all", &json!({})).unwrap();
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null());
}
#[test]
fn a_font_only_a_style_names_is_not_reported_missing() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "Body"})).unwrap();
s.run("styles.create", &json!({"name": "Unused Look"})).unwrap();
s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
// Creating a style applies it, as in Word: put the text back in Normal.
s.run("para.style", &json!({"style": "Normal"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
assert_eq!(missing_list(&mut s), Vec::<String>::new(), "no text uses the style");
// Text in the style, with no font of its own, is.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "Body"})).unwrap();
s.run("styles.create", &json!({"name": "Used Look"})).unwrap();
s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
s.run("para.style", &json!({"style": "Used Look"})).unwrap();
let list = missing_list(&mut s);
assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}");
}
#[test]
fn missing_fonts_are_what_layout_drew_in_shown_text() {
let mut doc = doc_of(vec![
para_in("one", "Wordcraft Nowhere Face"),
para_in("two", "wordcraft nowhere face"),
para_in("three", "JetBrains Mono"),
// Spaces, a picture's placeholder and zero-width characters show no font.
para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"),
]);
// Hidden text isn't laid out.
doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(
"secret",
wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() },
)));
// A text box story nothing shows any more (deleted, not yet pruned).
doc.parts.insert(
77,
wordcraft_doc::Part {
kind: wordcraft_doc::PartKind::TextBox,
blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))],
body: Default::default(),
},
);
let names: Vec<String> = missing_in(doc).into_iter().map(|f| f.font).collect();
assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}");
assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face"));
}
#[test]
fn metric_twins_are_listed_but_flagged_compatible() {
// A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's
// the substitute; a made-up family's substitute is never its twin.
let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")]));
let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed");
assert!(!nowhere.metric_compatible, "{nowhere:?}");
match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") {
// Arial is installed: not listed.
None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"),
Some(sub) => {
let arial = m.iter().find(|f| f.font == "Arial").expect("listed");
// An emoji drawn in a fallback face doesn't change the verdict on the substitute.
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}");
}
}
}
#[test]
fn a_missing_roman_font_drawn_in_cambria_is_reported() {
// Word draws a missing font the font table classes as roman in Cambria (#1613): installed or
// not, Cambria isn't the font the document asks for.
const ROMAN: &str = "Wordcraft Roman Gone";
let mut doc = doc_of(vec![para_in("Text", ROMAN)]);
doc.settings.roman_fonts = vec![ROMAN.into()];
let m = missing_in(doc.clone());
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [ROMAN], "{m:?}");
assert!(!m[0].metric_compatible, "{m:?}");
// The font box names the face layout draws, not the generic substitute.
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone();
assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria));
let mut s = Session::new(doc);
s.run("select.all", &json!({})).unwrap();
assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str());
}
#[test]
fn a_missing_east_asian_font_is_reported_for_cjk_text_only() {
let props =
wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() };
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())]));
assert_eq!(m.len(), 1, "{m:?}");
assert_eq!(m[0].font, "Wordcraft Nowhere Mincho");
assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}");
// Without CJK text the East Asian font draws nothing.
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty());
}
#[test]
fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("format.font", &json!({"name": "Wingdings"})).unwrap();
// Not installed, so it's drawn in another face, but on purpose: nothing to report.
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string());
}
let layout = s.layout();
assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty());
}
#[test]
fn missing_fonts_from_command_search_say_so_in_the_status_line() {
let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")]));
s.run("font.missing", &json!({})).unwrap();
assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status);
// Thousands of missing fonts: a few named, the rest counted.
let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect()));
s.run("font.missing", &json!({})).unwrap();
assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status);
}
#[test]
fn a_huge_font_name_is_kept_short() {
let name = format!("Wordcraft {}", "é".repeat(100_000));
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
assert_eq!(m.len(), 1);
assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len());
}
#[test]
fn symbol_fonts_drawn_as_unicode_are_not_reported() {
// Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents.
let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")]));
assert!(m.is_empty(), "{m:?}");
}
#[test]
fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() {
let props =
wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() };
let names = |text: &str| -> Vec<String> {
missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect()
};
assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font");
assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]);
// Without an East Asian font the CJK text asks for the Latin one.
let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() };
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)]));
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), ["Wordcraft Latin Gone"]);
}
#[test]
fn a_symbol_font_named_for_complex_script_text_is_not_reported() {
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() };
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty());
}
#[test]
fn a_hostile_number_of_missing_fonts_stays_bounded() {
let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect();
let m = missing_in(doc_of(paras));
assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len());
}
#[test]
fn a_font_key_matches_the_listed_name_however_long_or_padded() {
// Leading spaces and more than 256 characters: layout keeps 256, the list trims them.
let name = format!(" Wordcraft {}", "x".repeat(400));
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
assert_eq!(m.len(), 1);
assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font));
// The status line shortens such names.
let mut s = Session::new(doc_of(vec![para_in("Text", &name)]));
s.run("font.missing", &json!({})).unwrap();
assert!(s.status.chars().count() < 80, "{}", s.status);
}
#[test]
fn only_characters_that_draw_count() {
use wordcraft_layout::para::draws_something;
for c in ['a', '日', '€', '\u{1F600}'] {
assert!(draws_something(c), "{c:?}");
}
for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] {
assert!(!draws_something(c), "{c:?}");
}
}
#[test]
fn a_blank_font_name_is_not_a_missing_font() {
// A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name.
assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty());
}
#[test]
fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() {
// Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is
// installed; one of Arial's twins (Helvetica on a Mac) measures like it.
let m = missing_in(doc_of(vec![para_in("Text", "Arial ")]));
let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return };
let drawn = arial.shown_as.first().expect("drawn in a face");
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}");
}
#[test]
fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
// MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an
// East Asian font's text goes to the run's font or a fallback face, as the list says.
const MINCHO: &str = "Wordcraft Mincho Gone";
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() };
let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]);
doc.settings.roman_fonts = vec![MINCHO.into()];
let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed");
let m = missing_in(doc.clone());
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [MINCHO], "{m:?}");
assert_eq!(m[0].shown_as, [shown], "{m:?}");
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
}
+147 -20
View File
@@ -3,6 +3,7 @@
//! results are what Word produces for the same keys.
use serde_json::json;
use std::sync::Arc;
use wordcraft_doc::props::{BorderStyle, VertAlign};
use wordcraft_doc::{Pos, StoryRef};
@@ -483,7 +484,7 @@ fn autocorrect_replacement_list_follows_the_texts_language() {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
let mut s = self::s();
s.set_editing_language(Some("pl-PL"));
s.doc.styles.default_chr.lang = None;
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
typ(&mut s, "Ala i kot ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language");
// `eng` is English too, as for proofing.
@@ -833,6 +834,28 @@ fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
assert!(bottom(&s, 0).is_none());
}
#[test]
fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() {
use wordcraft_doc::props::Align;
// Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting
// change is Ben's and holds Ana's centered paragraph without a border.
let mut s = typed("---");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "para.align", json!({"value": "center"}));
s.author = "Ben".into();
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
let ch = p.props.fmt_change.as_deref();
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false)));
run(&mut s, "review.rejectAll", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false));
}
#[test]
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
let mut s = typed("+--+------+");
@@ -1065,7 +1088,7 @@ fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
assert_eq!(indents(&s), (None, None));
// A list level past the last one, from a style or set directly, goes to the last level.
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
run(&mut s, "caret.docStart", json!({}));
@@ -1169,6 +1192,10 @@ fn two_enters_after_a_short_line_make_it_a_heading() {
let s = with_heading_styles("One two three four five\n\n");
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
}
#[test]
fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() {
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
let s = with_heading_styles(keys);
@@ -1186,15 +1213,26 @@ fn two_enters_after_a_short_line_make_it_a_heading() {
// A list item numbered by the Normal style is not body text either.
let mut q = self::s();
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
if let Some(normal) = q.doc.styles.get_mut("Normal") {
let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") {
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
}
typ(&mut q, "Item\n\n");
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
// Off by default.
// A line with an outline level stays; the body-text level (9 and above) is no outline level.
for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] {
let mut q = with_heading_styles("Title\n");
q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level);
typ(&mut q, "\n");
assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}");
}
}
#[test]
fn built_in_heading_styles_are_off_by_default() {
let s = typed("Introduction\n\n");
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
let h = |t: &str| (t.to_string(), None);
assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]);
}
#[test]
@@ -1211,6 +1249,18 @@ fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
#[test]
fn a_tracked_heading_autoformat_can_be_rejected() {
// Enter in the empty paragraph turns a zero-tab line into Heading 1.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
let ch = p.props.fmt_change.as_deref();
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
// With Track Changes on, the tab is marked deleted and the style change is a formatting
// revision, so Reject All brings the line back as it was.
let mut s = with_heading_styles("\tMethods\n");
@@ -1244,6 +1294,64 @@ fn a_tracked_heading_autoformat_can_be_rejected() {
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
}
#[test]
fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() {
use wordcraft_doc::props::Align;
// Ana centers a zero-tab line; Ben's Enter makes it Heading 1.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
run(&mut s, "para.align", json!({"value": "center"}));
s.author = "Ben".into();
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
let ch = p.props.fmt_change.as_deref();
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben"));
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
run(&mut s, "review.rejectAll", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
// Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is
// removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
run(&mut s, "para.align", json!({"value": "center"}));
s.author = "Ben".into();
typ(&mut s, "\t");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
let ch = p.props.fmt_change.as_deref();
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
// Rejecting it brings back Ana's centered Normal line.
run(&mut s, "review.rejectAll", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
// The author's own earlier change keeps the formatting before it.
let mut s = with_heading_styles("Title\n");
run(&mut s, "review.trackChanges", json!({"value": true}));
s.author = "Ana".into();
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
run(&mut s, "para.align", json!({"value": "center"}));
typ(&mut s, "\t");
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
typ(&mut s, "\n");
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
let ch = p.props.fmt_change.as_deref();
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None));
assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}");
}
#[test]
fn a_locked_heading_style_is_not_applied_as_you_type() {
let mut s = s();
@@ -1255,6 +1363,25 @@ fn a_locked_heading_style_is_not_applied_as_you_type() {
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
}
#[test]
fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() {
// Read only, with an editable region from the end of "Title" to the start of "End.": Enter
// twice in the region breaks the paragraph and leaves the protected line Normal text, and a
// border line typed there goes under the paragraph above it, inside the region.
let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd."));
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) };
run(&mut s, "review.restrict", json!({"exception": "add"}));
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
run(&mut s, "select.text", json!({"text": "X"}));
typ(&mut s, "\n\n");
assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s));
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
typ(&mut s, "---\n");
assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some());
assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd.");
}
#[test]
fn arrow_bullets() {
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
@@ -2036,7 +2163,7 @@ fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
// A style that sets left-to-right wins.
let mut doc = Session::new(wordcraft_doc::Document::new());
if let Some(st) = doc.doc.styles.get_mut("Normal") {
if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") {
st.para.bidi = Some(false);
}
typ(&mut doc, "سلام");
@@ -3043,15 +3170,15 @@ fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -
use wordcraft_doc::props::NumRef;
use wordcraft_doc::styles::Style;
let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta");
let num = doc.numbering.try_add_list(kind).unwrap();
doc.styles.styles.push(Style {
let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap();
Arc::make_mut(&mut doc.styles).styles.push(Style {
id: "ListBase".into(),
name: "Original List".into(),
para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() },
..Default::default()
});
if chain {
doc.styles.styles.push(Style {
Arc::make_mut(&mut doc.styles).styles.push(Style {
id: "ListChild".into(),
name: "Original Derived".into(),
based_on: Some("ListBase".into()),
@@ -3127,13 +3254,13 @@ fn list_toggle_uses_effective_level_and_override() {
for explicit_kind in [false, true] {
let mut s = style_inherited_list(kind, true);
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
let num = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
num.level = 1;
let num_id = num.num;
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
if override_level {
s.doc.numbering.abstracts[0].levels[1] = levels_for(opposite)[1].clone();
s.doc.numbering.nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone();
Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
}
let before = s.doc.clone();
let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) };
@@ -3162,7 +3289,7 @@ fn list_paragraph_style_survives_inherited_toggle_off() {
use wordcraft_doc::styles::Style;
for kind in [ListKind::Bullet, ListKind::Numbered] {
let mut s = style_inherited_list(kind, false);
*s.doc.styles.styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
*Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
id: "ListParagraph".into(),
name: "Original formatted list".into(),
based_on: Some("ListBase".into()),
@@ -3217,7 +3344,7 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
let mut s = style_inherited_list(kind, false);
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
let num = s.doc.numbering.nums[0].id;
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 });
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default();
@@ -3273,7 +3400,7 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
run(&mut s, "format.highlight", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
run(&mut s, "format.highlight", json!({"color":"blue"}));
run(&mut s, "format.highlight", json!({}));
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
@@ -3335,7 +3462,7 @@ fn highlight_toggle_checks_past_the_resolved_run_limit() {
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
.collect();
if inherited {
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow);
} else {
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
}
@@ -3395,7 +3522,7 @@ fn highlight_toggle_checks_whole_cell_marks() {
Pos::new(StoryRef::Body, path, 0)
};
if inherited {
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow);
}
for row in 0..2 {
for col in 0..2 {
@@ -3447,7 +3574,7 @@ fn highlight_toggle_checks_ordinary_paragraph_marks() {
use wordcraft_doc::props::Highlight;
for text in ["AB", "AB\n\nAB", "\n"] {
let mut doc = wordcraft_doc::Document::from_text(text);
doc.styles.default_chr.highlight = Some(Highlight::Blue);
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue);
let paths = doc.para_paths(StoryRef::Body);
for path in &paths {
let p = doc.para_mut(StoryRef::Body, path).unwrap();
+2 -2
View File
@@ -61,7 +61,7 @@ pub struct ViewState {
pub read_width: f32,
#[serde(skip, default = "default_read_height")]
pub read_height: f32,
/// Reader-only layout controls; they are neither document formatting nor saved UI prefs.
/// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs.
/// `None` chooses responsive column width and keeps the document's line spacing.
#[serde(skip)]
pub read_column_width: Option<f32>,
@@ -69,7 +69,7 @@ pub struct ViewState {
pub read_zoom: f32,
#[serde(skip)]
pub read_line_spacing: Option<f32>,
/// Read Mode's Page Color: the paper the reading columns are drawn on.
/// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value.
#[serde(skip)]
pub read_page_color: crate::cmd::view::ReadPageColor,
pub dark_mode: bool,
+63
View File
@@ -192,6 +192,41 @@ pub fn charset_base_family(family: &str) -> &str {
.map_or(family, str::trim_end)
}
/// Fonts made to measure alike (the same advance widths and line height), so text drawn in one
/// wraps and paginates as in the others: Carlito for Calibri, Caladea for Cambria, Liberation and
/// Croscore fonts for Arial, Times New Roman and Courier New, the Palatino look-alikes for Palatino
/// Linotype and Book Antiqua, and Arial and Helvetica (drawn to Helvetica's widths) for one
/// another. The one list of them: a missing font is drawn in its first installed twin
/// ([`substitute`]), and text drawn in a twin doesn't change the layout ([`metric_compatible`]).
/// Not macOS Times or Courier: their advance widths differ from Times New Roman's and Courier
/// New's.
const METRIC_TWINS: &[&[&str]] = &[
&["Arial", "Liberation Sans", "Helvetica", "Arimo"],
&["Times New Roman", "Liberation Serif", "Tinos"],
&["Courier New", "Liberation Mono", "Cousine"],
&["Calibri", "Carlito"],
&["Cambria", "Caladea"],
&["Palatino Linotype", "Palatino", "Book Antiqua", "TeX Gyre Pagella", "P052", "URW Palladio L", "Palladio"],
];
/// The fonts that measure like `family` ([`METRIC_TWINS`]), in the order they are tried, without
/// `family` itself.
fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
let f = family.trim();
METRIC_TWINS
.iter()
.filter(move |g| g.iter().any(|t| t.eq_ignore_ascii_case(f)))
.flat_map(|g| g.iter().copied())
.filter(move |t| !t.eq_ignore_ascii_case(f))
}
/// Whether `substitute` was made to measure like `family` ([`METRIC_TWINS`]), so text drawn in it
/// wraps and paginates as in `family`.
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
let s = substitute.trim();
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
}
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
/// Persian font packs; matched by name only).
fn is_persian_naskh(f: &str) -> bool {
@@ -343,6 +378,10 @@ fn is_hebrew(f: &str) -> bool {
/// letters and digits as well as Thai ones (a Thai face without Latin would leave a mixed run in
/// two fonts, #1549), else the first with Thai letters, else the first installed.
fn substitute(db: &FontDb, family: &str) -> Option<&'static str> {
// A twin draws the text as the font would: Carlito for Calibri, Helvetica for Arial on a Mac.
if let Some(twin) = metric_twins(family).find(|t| db.has_family(t)) {
return Some(twin);
}
let mut installed = substitutes(family).iter().copied().filter(|s| db.has_family(s));
if !is_thai_family(family) {
return installed.next();
@@ -477,6 +516,30 @@ mod tests {
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > 1000));
}
#[test]
fn metric_compatible_substitutes_are_known() {
assert!(metric_compatible("Calibri", "Carlito"));
assert!(metric_compatible(" arial ", "Liberation Sans"));
assert!(metric_compatible("Arial CE", "Helvetica"));
assert!(metric_compatible("Book Antiqua", "TeX Gyre Pagella"));
assert!(!metric_compatible("Calibri", "Helvetica Neue"));
assert!(!metric_compatible("Open Sans", "Carlito"));
assert!(!metric_compatible("Times New Roman", "Times"));
assert_eq!(metric_twins("arial").collect::<Vec<_>>(), ["Liberation Sans", "Helvetica", "Arimo"]);
}
#[test]
fn a_missing_font_is_drawn_in_an_installed_twin() {
// The twins that decide the font box's warning are the faces layout draws in.
let db = FontDb::global();
for family in METRIC_TWINS.iter().flat_map(|g| g.iter()) {
let r = resolve(family, false, false);
if r.substituted && metric_twins(family).any(|t| db.has_family(t)) {
assert!(metric_compatible(family, &r.face.family), "{family} → {}", r.face.family);
}
}
}
#[test]
fn unknown_family_substitutes() {
let r = resolve("Definitely Not A Font 123", false, false);
+6 -2
View File
@@ -56,9 +56,10 @@ pub fn notes(doc: &Document) -> Vec<Note> {
let mut open: Vec<u32> = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (off, c) in p.text.char_indices() {
let mut objects = p.objects.iter();
for c in p.text.chars() {
if c == OBJ {
match p.object_at(off) {
match objects.next() {
Some(InlineObject::CommentStart { id }) => {
if seen.insert(*id) {
order.push(*id);
@@ -198,3 +199,6 @@ fn text(notes: &[Note]) -> String {
}
s
}
#[cfg(test)]
mod tests;
+108
View File
@@ -0,0 +1,108 @@
use super::*;
use wordcraft_doc::{Block, CharProps, Comment, Paragraph, PartKind, Run, Table, para_block};
fn paragraph(text: String, objects: Vec<InlineObject>) -> Paragraph {
let len = text.len();
Paragraph { text, objects, runs: vec![Run { len, props: CharProps::default() }], ..Paragraph::new() }
}
fn comment(doc: &mut Document, id: u32, author: &str, parent: Option<u32>, text: &str) {
let part = doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
doc.comments.insert(id, Comment { author: author.into(), part, parent, ..Default::default() });
}
#[test]
fn marker_walk_preserves_comment_quotes_order_and_unpaired_objects() {
let mut doc = Document::new();
comment(&mut doc, 9, " Ada ", None, "First note");
comment(&mut doc, 3, " ", Some(9), "Reply text");
comment(&mut doc, 5, "", None, "Second note");
comment(&mut doc, 7, "Grace", None, "Unanchored");
comment(&mut doc, 12, "Linus", None, "Cell note");
doc.body = vec![para_block(paragraph(
format!("é漢{OBJ}α {OBJ}β{OBJ} γ{OBJ}{OBJ}"),
vec![
InlineObject::CommentStart { id: 9 },
InlineObject::CommentStart { id: 5 },
InlineObject::CommentEnd { id: 9 },
InlineObject::CommentEnd { id: 5 },
// No marker for this object: it must not anchor the unanchored comment.
InlineObject::BookmarkStart { name: "unrelated".into() },
InlineObject::CommentStart { id: 7 },
],
))];
// Missing object storage must not replay an earlier comment marker.
doc.body.push(para_block(paragraph(format!("{OBJ}{OBJ}tail"), vec![])));
let mut table = Table::new(1, 1, 200.0);
table.rows[0].cells[0].blocks =
vec![para_block(paragraph(format!("{OBJ}cell{OBJ}"), vec![InlineObject::CommentStart { id: 12 }, InlineObject::CommentEnd { id: 12 }]))];
doc.body.push(std::sync::Arc::new(Block::Table(table)));
let before = doc.clone();
let actual = notes(&doc);
assert_eq!(
actual,
vec![
Note { author: "Ada".into(), quote: "α β".into(), text: "First note".into(), replies: vec![("Author".into(), "Reply text".into())] },
Note { author: "Author".into(), quote: "β γ".into(), text: "Second note".into(), replies: vec![] },
Note { author: "Linus".into(), quote: "cell".into(), text: "Cell note".into(), replies: vec![] },
Note { author: "Grace".into(), quote: "".into(), text: "Unanchored".into(), replies: vec![] },
]
);
assert_eq!(doc, before, "export discovery must leave the document unchanged");
}
fn dense_markers(n: usize) -> Document {
let mut doc = Document::new();
comment(&mut doc, 9, "Ada", None, "Kept note");
let mut objects = Vec::with_capacity(2 * n + 2);
for k in 0..n {
objects.push(InlineObject::BookmarkStart { name: format!("bookmark{k}") });
objects.push(InlineObject::BookmarkEnd { name: format!("bookmark{k}") });
}
objects.extend([InlineObject::CommentStart { id: 9 }, InlineObject::CommentEnd { id: 9 }]);
doc.body = vec![para_block(paragraph(format!("{}{OBJ}é漢 quote{OBJ}", OBJ.to_string().repeat(2 * n)), objects))];
doc
}
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
// performance evidence; the semantic assertions below still run on every platform.
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
#[test]
fn comment_export_discovery_scales_with_unrelated_markers() {
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
assert_eq!(notes(&small), expected);
assert_eq!(notes(&big), expected);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| {
for _ in 0..8 {
std::hint::black_box(notes(&small));
}
});
let b = cpu_cost(|| {
std::hint::black_box(notes(&big));
});
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
} else {
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
}
}
+2
View File
@@ -42,5 +42,7 @@ pub use export::{esc, export, export_flow, export_fragment, inlines_html};
use import::parse_in;
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
#[cfg(test)]
use css::css_unescape;
#[cfg(test)]
mod tests;
+43 -1
View File
@@ -46,6 +46,48 @@ pub(super) fn strip_important(v: &str) -> &str {
s
}
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
/// form feed count as one newline, as CSS reads them (§3.3).
pub(super) fn css_unescape(s: &str) -> String {
let mut o = String::with_capacity(s.len());
let mut it = s.chars().peekable();
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
Some('\r') => {
it.next();
it.next_if_eq(&'\n');
}
Some(' ' | '\t' | '\n' | '\u{C}') => {
it.next();
}
_ => {}
};
while let Some(c) = it.next() {
if c != '\\' {
o.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
hex.extend(it.next());
}
if hex.is_empty() {
// Before a newline it continues the line; at the end it is dropped.
match it.peek() {
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
Some(_) => o.extend(it.next()),
None => {}
}
continue;
}
skip_space(&mut it);
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
}
o
}
/// Apply a `style` attribute to formatting / block properties.
pub(super) fn apply_style(
css: &str,
@@ -88,7 +130,7 @@ pub(super) fn apply_style(
}
}
"font-family" => {
let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string();
let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
f.code = true;
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
+19 -1
View File
@@ -19,6 +19,24 @@ pub fn esc(s: &str) -> String {
o
}
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
/// these are written as hex escapes ([`css_unescape`] reads them back).
fn css_font_name(name: &str) -> String {
let mut o = String::with_capacity(name.len() + 2);
o.push('\'');
for c in name.chars() {
match c {
'\0' => o.push('\u{FFFD}'),
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
c => o.push(c),
}
}
o.push('\'');
o
}
fn align_css(a: Option<Align>) -> Option<&'static str> {
match a? {
Align::Center => Some("text-align:center"),
@@ -92,7 +110,7 @@ fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
css.push(format!("font-size:{}pt", trim_num(s)));
}
if let Some(fam) = &f.font {
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
css.push(format!("font-family:{}", esc(&css_font_name(fam))));
}
if !css.is_empty() {
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
+38
View File
@@ -314,6 +314,44 @@ fn leading_space_survives_round_trip() {
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
}
#[test]
fn css_escapes_read_every_kind_of_newline() {
// A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one
// of them (CR LF as one) ends the escape.
assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE");
assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD");
assert_eq!(css_unescape("x\\'y\\"), "x'y");
}
#[test]
fn font_names_are_written_as_css_strings() {
// A font name could end the CSS string or the declaration and add CSS of its own (#3739).
let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"];
let mut p = Para::default();
for (i, font) in fonts.iter().enumerate() {
p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() });
}
let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }));
assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}");
let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect();
assert_eq!(styles.len(), fonts.len(), "{html}");
for s in styles {
assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}");
}
// Read back, each run has its font as it was.
let back = parse(&html);
let runs: Vec<(String, Option<String>)> = back
.blocks
.iter()
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
.flat_map(|p| p.inlines.iter())
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None })
.collect();
for (i, font) in fonts.iter().enumerate() {
assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}");
}
}
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
fn ruby_doc(base: &str, ruby: &str) -> Document {
let props = wordcraft_doc::CharProps::default();
+8
View File
@@ -12,6 +12,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use wordcraft_doc::table::{MAX_COLS, MAX_ROWS};
use wordcraft_doc::{Align, Document};
use crate::html::decode_entity;
@@ -21,6 +22,11 @@ mod blocks;
mod export;
mod inlines;
/// Table cells a Markdown document makes in all, header rows included (hostile input). A short
/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
pub use blocks::{Refs, import, import_on, parse};
pub(crate) use export::escape;
pub use export::export;
@@ -28,5 +34,7 @@ pub use export::export;
use export::{escape_line_start, inlines_md, table_md, write_blocks};
pub use inlines::parse_inlines;
#[cfg(test)]
use blocks::split_row;
#[cfg(test)]
mod tests;
+86 -39
View File
@@ -1,5 +1,5 @@
use super::inlines::parse_inlines;
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, Para, model};
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model};
/// Nesting limit for block containers (quotes, list items) and inline links.
pub(super) const MAX_NEST: usize = 32;
@@ -143,7 +143,8 @@ pub fn parse(text: &str) -> model::Flow {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
let refs = take_definitions(&mut lines);
let mut blocks = parse_blocks(&lines, 0, 0, &refs);
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
@@ -271,47 +272,69 @@ fn list_marker(t: &str) -> Option<Marker> {
if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None }
}
fn split_row(l: &str) -> Vec<String> {
/// A table row without its leading and trailing pipes (an escaped last pipe stays).
fn row_body(l: &str) -> &str {
let t = l.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = if let Some(before) = t.strip_suffix('|') {
if let Some(before) = t.strip_suffix('|') {
let num_bs = before.chars().rev().take_while(|&c| c == '\\').count();
if num_bs % 2 == 0 { before } else { t }
} else {
t
};
}
}
/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted.
pub(super) fn split_row(l: &str, keep: usize) -> (Vec<String>, usize) {
let mut cells = Vec::new();
let mut count = 0usize;
let mut cur = String::new();
let mut chars = t.chars().peekable();
let mut chars = row_body(l).chars().peekable();
while let Some(c) = chars.next() {
let full = cells.len() >= keep;
match c {
'\\' => match chars.peek() {
Some(&'|') => {
cur.push('|');
if !full {
cur.push('|');
}
chars.next();
}
Some(&'\\') => {
cur.push('\\');
cur.push('\\');
if !full {
cur.push_str("\\\\");
}
chars.next();
}
_ => cur.push('\\'),
_ if !full => cur.push('\\'),
_ => {}
},
'|' => cells.push(std::mem::take(&mut cur).trim().to_string()),
_ => cur.push(c),
'|' => {
if !full {
cells.push(std::mem::take(&mut cur).trim().to_string());
}
count += 1;
}
_ if !full => cur.push(c),
_ => {}
}
}
cells.push(cur.trim().to_string());
cells
if cells.len() < keep {
cells.push(cur.trim().to_string());
}
(cells, count + 1)
}
fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every
/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row.
fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
if !l.contains('-') {
return None;
}
let cells = split_row(l);
let mut out = Vec::new();
for c in cells {
let mut count = 0usize;
// A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one.
for c in row_body(l).split('|') {
let c = c.trim();
let left = c.starts_with(':');
let right = c.ends_with(':');
@@ -319,14 +342,17 @@ fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
if core.is_empty() || !core.chars().all(|x| x == '-') {
return None;
}
out.push(match (left, right) {
(true, true) => Some(Align::Center),
(false, true) => Some(Align::Right),
(true, false) => Some(Align::Left),
_ => None,
});
if out.len() < keep {
out.push(match (left, right) {
(true, true) => Some(Align::Center),
(false, true) => Some(Align::Right),
(true, false) => Some(Align::Left),
_ => None,
});
}
count += 1;
}
Some(out)
Some((out, count))
}
/// Does this line start a block that interrupts a paragraph?
@@ -386,7 +412,8 @@ fn code_para(line: &str) -> FBlock {
}
/// Parse block structure. `level` is the list nesting level for lists found here.
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<FBlock> {
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
let mut out = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut i = 0;
@@ -460,32 +487,52 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
parse_quote(lines, &mut i, depth, level, refs, &mut out);
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
continue;
}
if para.is_empty()
&& t.contains('|')
&& let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l))
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
{
let head = split_row(t);
if head.len() == aligns.len() {
let (head, head_width) = split_row(t, MAX_COLS);
if head_width == width {
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
// The document keeps no more columns or rows than this, nor cells past its budget,
// so those aren't built; their lines are still the table's.
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
budget.set(budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
i += 2;
while let Some(l) = lines.get(i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
let mut cells = split_row(l);
cells.resize(aligns.len(), String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
if !table.rows.is_empty() && fits(table.rows.len()) {
budget.set(budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
i += 1;
}
out.push(FBlock::Table(table));
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
out.push(FBlock::Table(table));
}
continue;
}
}
@@ -493,7 +540,7 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
{
flush_para(&mut para, &mut out, depth, refs);
i = parse_list(lines, i, depth, level, &mut out, refs);
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
continue;
}
para.push(line.clone());
@@ -521,7 +568,7 @@ fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
out.extend(code.into_iter().map(code_para));
}
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, out: &mut Vec<FBlock>) {
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
let mut inner = Vec::new();
while let Some(line) = lines.get(*i) {
let trimmed = line.trim_start_matches([' ', '\t']);
@@ -541,7 +588,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
out.push(FBlock::Para(p));
return;
}
for mut block in parse_blocks(&inner, depth + 1, level, refs) {
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
if let FBlock::Para(p) = &mut block
&& p.kind == Kind::Normal
{
@@ -579,7 +626,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
}
/// Parse a list starting at `lines[i]`; returns the index after it.
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs) -> usize {
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
while let Some(line) = lines.get(i) {
let ind = indent(line);
@@ -626,7 +673,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
p.push_text(&item.join(" "), &Fmt::default());
vec![FBlock::Para(p)]
} else {
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs)
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
};
let mut placed = false;
if inner.is_empty() {
+61
View File
@@ -356,6 +356,67 @@ fn three_column_table_with_distinctive_text() {
assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code));
}
#[test]
fn table_cells_past_the_document_limits_are_not_built() {
// Every body row was padded to the header's width before any cap (#3701).
let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000));
crate::model::begin_import();
let flow = parse(&wide);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), 3);
assert!(t.rows.iter().all(|r| r.len() == MAX_COLS));
assert_eq!(t.rows[2][0].text(), "b");
assert!(crate::model::Dropped::TableCells.reported(), "the columns left out are logged");
// Rows past the limit still belong to the table, so the text after it reads as before.
let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10));
crate::model::begin_import();
let flow = parse(&tall);
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
assert_eq!(t.rows.len(), MAX_ROWS);
let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") };
assert_eq!(p.text(), "after");
assert_eq!(flow.blocks.len(), 2);
assert!(crate::model::Dropped::TableCells.reported(), "the rows left out are logged");
}
/// A short row is padded to its header's width, so a small file of one-cell rows under wide
/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell
/// budget: rows past it are left out and logged, and their lines make no paragraphs.
#[test]
fn tables_share_one_cell_budget() {
let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|"));
let fits = crate::markdown::MAX_TABLE_CELLS / MAX_COLS;
let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2));
crate::model::begin_import();
let flow = parse(&src);
let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect();
let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum();
assert!(cells <= crate::markdown::MAX_TABLE_CELLS, "{cells} cells");
assert_eq!(tables.iter().map(|t| t.rows.len()).collect::<Vec<_>>(), [fits], "the second table is past the budget");
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["after"]);
assert!(crate::model::Dropped::TableCells.reported());
// Within the budget a table is whole and nothing is logged.
crate::model::begin_import();
let flow = parse(&format!("{}| 1 | 2 |\n", header(2)));
let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) };
assert_eq!(t.rows.len(), 2);
assert!(!crate::model::Dropped::TableCells.reported());
}
#[test]
fn a_wide_table_still_checks_every_header_and_delimiter_cell() {
// Only the first 63 cells are kept, but every one is counted and checked.
let head = "|a".repeat(100) + "|\n";
let delim = |bad: Option<usize>| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::<String>() + "|\n";
let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_)));
assert!(is_table(format!("{head}{}| b |\n", delim(None))));
assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones");
assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells");
let (cells, n) = split_row(r"| a | b \| c | d |", 2);
assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3));
}
#[test]
fn regular_table_cells_containing_escaped_pipes() {
let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n";
+2 -2
View File
@@ -290,7 +290,7 @@ impl<'a> Builder<'a> {
level
})
.collect();
let numbering = &mut self.doc.numbering;
let numbering = Arc::make_mut(&mut self.doc.numbering);
let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels, ..Default::default() });
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
@@ -425,7 +425,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document
doc.numbering = Default::default();
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
if uses_code {
doc.styles.upsert(code_style());
Arc::make_mut(&mut doc.styles).upsert(code_style());
}
let body = Builder::new(&mut doc).blocks(&flow.blocks, 0);
doc.body = body;
+19
View File
@@ -54,6 +54,10 @@ pub(crate) enum Dropped {
RunStrings,
/// A style name cut to [`MAX_FONT_NAME`].
StyleName,
/// ODT `text:s` spaces past the document's budget.
Spaces,
/// Markdown table rows and columns past the table limits or the document's cell budget.
TableCells,
}
impl Dropped {
@@ -65,8 +69,23 @@ impl Dropped {
format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them")
}
Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"),
Dropped::Spaces => {
"import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string()
}
Dropped::TableCells => format!(
"import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out",
wordcraft_doc::table::MAX_COLS,
wordcraft_doc::table::MAX_ROWS,
crate::markdown::MAX_TABLE_CELLS
),
}
}
/// Whether this thread's import has logged this kind ([`dropped`]).
#[cfg(test)]
pub(crate) fn reported(self) -> bool {
REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0)
}
}
/// Log that the importer left something of `kind` out: once per kind per imported document
+1 -1
View File
@@ -43,7 +43,7 @@ use import::Pages;
use import::{Pictures, apply_page_layout, read_entry, read_meta};
pub use import::{import, parse};
#[cfg(test)]
use limits::MAX_XML_DEPTH;
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
pub use package::export;
use styles::Styles;
#[cfg(test)]
+13 -3
View File
@@ -1,5 +1,5 @@
use super::import::Pages;
use super::limits::{MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
use super::limits::{MAX_EXTRA_SPACES, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
use super::styles::Styles;
use super::xml::{Attrs, Ev, events, get, length, local};
use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model};
@@ -116,6 +116,8 @@ pub(super) struct Body<'a> {
para: Option<Para>,
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
xml_space_end: bool,
/// What is left of [`MAX_EXTRA_SPACES`].
pub(super) spaces_left: usize,
para_cont: bool,
/// Open lists: (style name, item open, item used).
lists: Vec<(Option<String>, bool)>,
@@ -169,6 +171,7 @@ impl<'a> Body<'a> {
fmt: Vec::new(),
para: None,
xml_space_end: false,
spaces_left: MAX_EXTRA_SPACES,
para_cont: false,
lists: Vec::new(),
lists_saved: Vec::new(),
@@ -821,9 +824,16 @@ impl Body<'_> {
}
self.fmt.push(fmt);
}
"s" => {
// Outside a paragraph `push_text` drops spaces, so they cost no budget.
"s" if self.para.is_some() => {
let count = get(a, "text:c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1000);
self.push_text(&" ".repeat(count));
let want = count.saturating_sub(1);
let extra = want.min(self.spaces_left);
if extra < want {
model::dropped(model::Dropped::Spaces);
}
self.spaces_left -= extra;
self.push_text(&" ".repeat(1 + extra));
}
"tab" => self.push_text("\t"),
"line-break" => self.push_text("\n"),
+7 -3
View File
@@ -1,8 +1,9 @@
use super::body::Body;
use super::limits::{MAX_ENTRY, MAX_SECTIONS};
use super::limits::{MAX_ENTRY, MAX_EXTRA_SPACES, MAX_SECTIONS};
use super::styles::{Styles, column_sep, columns_of};
use super::xml::{Attrs, Ev, events, get, length, local};
use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts};
use std::sync::Arc;
pub(super) fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
let f = z.by_name(name).ok()?;
@@ -138,8 +139,11 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Opti
// A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded.
pictures.get(&mut z, path)
};
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch)).unwrap_or_default();
// One `text:s` budget for the headers and footers and then the body.
let mut spaces_left = MAX_EXTRA_SPACES;
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, &mut spaces_left)).unwrap_or_default();
let mut body = Body::new(&styles, &pages, &mut fetch);
body.spaces_left = spaces_left;
events(&content, |ev| body.feed(ev));
let mut blocks = body.take_blocks();
// An open section with nothing in it (after a column section at the end) isn't one: the
@@ -164,7 +168,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
let (flow, last, track, font, parts) = parse_tracked(bytes)?;
let mut doc = model::to_doc(&flow);
if font.is_some() {
doc.styles.default_chr.font = font;
Arc::make_mut(&mut doc.styles).default_chr.font = font;
}
doc.last_section = last;
page_parts::add(&mut doc, parts);
+2
View File
@@ -15,3 +15,5 @@ pub(super) const MAX_OPEN_INSERTIONS: usize = 64;
/// Longest change author or date kept, in characters.
pub(super) const MAX_INFO_CHARS: usize = 256;
/// Most spaces the document's `text:s` elements add beyond the one each stands for (hostile input).
pub(super) const MAX_EXTRA_SPACES: usize = 4 << 20;
+5 -2
View File
@@ -58,8 +58,9 @@ pub(super) struct Parts {
/// Read each visible header and footer of the master pages in `pages`, and name it on its master
/// page's section as part id 1, 2, … in order, so the sections the body starts carry them.
/// [`add`] puts those parts into the document.
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>) -> Parts {
/// [`add`] puts those parts into the document. `spaces_left` is the document's
/// [`MAX_EXTRA_SPACES`] budget, which the body read after them goes on with.
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>, spaces_left: &mut usize) -> Parts {
let mut found: Vec<(String, Slot, Vec<Ev>)> = Vec::new();
let mut master: Option<String> = None;
// The slot being read, its nesting depth and whether it is kept.
@@ -109,9 +110,11 @@ pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut d
for (name, kind, evs) in found {
let mut reader = Body::new(styles, &none, fetch);
reader.in_text = true;
reader.spaces_left = *spaces_left;
for ev in evs {
reader.feed(ev);
}
*spaces_left = reader.spaces_left;
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
if let Some(sec) = pages.masters.get_mut(&name) {
kind.set(sec, out.parts.len() as u32);
+50
View File
@@ -488,6 +488,24 @@ fn deeply_nested_header_content_does_not_panic() {
assert_eq!(part_text(&d, d.last_section.footers.default), "After");
}
#[test]
fn headers_and_footers_share_the_text_s_budget_with_the_body() {
// Each header and footer was read with a budget of its own (#3700).
let run = r#"<text:s text:c="1000"/>"#.repeat(3_000);
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>h{run}</text:p></style:header><style:footer><text:p>f{run}</text:p></style:footer></style:master-page></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>b{run}</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let texts =
[part_text(&d, d.last_section.headers.default), part_text(&d, d.last_section.footers.default), d.plain_text(wordcraft_doc::StoryRef::Body)];
// One space per element in all three, and one budget of extra spaces between them.
assert_eq!(texts.iter().map(|t| t.matches(' ').count()).sum::<usize>(), 3 * 3_000 + MAX_EXTRA_SPACES);
assert!(texts[0].starts_with("h ") && texts[1].starts_with("f ") && texts[2].starts_with("b "));
}
#[test]
fn master_pages_and_column_sections_become_sections() {
use wordcraft_doc::section::SectionStart;
@@ -1049,6 +1067,38 @@ fn explicit_spaces_at_paragraph_ends_are_kept() {
}
}
#[test]
fn text_s_spaces_have_a_budget_for_the_whole_document() {
// 5,000 elements asking for 1,000 spaces each would add 5 million (#3700).
let tags = 5_000;
let run = r#"<text:s text:c="1000"/>"#.repeat(tags);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>a{run}b</text:p><text:p>c<text:s text:c="3"/>d</text:p></office:text></office:body></office:document-content>"#
);
crate::model::begin_import();
let f = parse(&package(&content, None)).unwrap();
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
// Once the budget is spent, each element still stands for one space, and the cut is logged.
assert_eq!(texts[0].len(), 2 + tags + MAX_EXTRA_SPACES);
assert!(texts[0].starts_with("a ") && texts[0].ends_with(" b"));
assert_eq!(texts[1], "c d");
assert!(crate::model::Dropped::Spaces.reported());
}
#[test]
fn text_s_outside_a_paragraph_spends_no_budget() {
// Spaces outside a paragraph are dropped, so they mustn't use up the budget (#3700).
let stray = r#"<text:s text:c="1000"/>"#.repeat(5_000);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text>{stray}<text:p>a<text:s text:c="1000"/>b</text:p></office:text></office:body></office:document-content>"#
);
crate::model::begin_import();
let f = parse(&package(&content, None)).unwrap();
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
assert_eq!(texts, vec![format!("a{}b", " ".repeat(1000))]);
assert!(!crate::model::Dropped::Spaces.reported(), "nothing was cut");
}
#[test]
fn test_asian_complex_and_extended_text_properties() {
let content = format!(
+10 -2
View File
@@ -33,6 +33,12 @@ pub fn esc(s: &str) -> String {
o
}
/// [`esc`] for a `\fonttbl` name: `;` would end the entry, so it is written `\'3b`, and control
/// characters, which `esc` writes as `\line` or `\tab`, are dropped.
fn esc_font(name: &str) -> String {
esc(&name.replace(char::is_control, "")).replace(';', "\\'3b")
}
pub(super) struct Tables {
pub(super) fonts: Vec<String>,
pub(super) colors: Vec<Rgb>,
@@ -235,7 +241,9 @@ impl Writer {
// leaks the reader's NTLM credentials.
match f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
Some(l) => {
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&l.replace('"', "%22")), body));
// In a field's quotes `\\` is one backslash; a quote is stored as `%22`.
let target = l.replace('\\', "\\\\").replace('"', "%22");
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&target), body));
}
None => out.push_str(&body),
}
@@ -682,7 +690,7 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl");
for (i, f) in w.t.fonts.iter().enumerate() {
let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" };
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc(f)));
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f)));
}
out.push_str("}\n{\\colortbl;");
for c in &w.t.colors {
+3 -2
View File
@@ -1,6 +1,7 @@
use super::reader::Reader;
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
use super::{Cell, Document, FBlock, Flow, Para, model};
use std::sync::Arc;
/// Parse RTF bytes into the flow model.
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
@@ -305,12 +306,12 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
}
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
// wants beyond that it states on each paragraph.
let dp = &mut doc.styles.default_para;
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
dp.space_before = Some(0.0);
dp.space_after = Some(0.0);
dp.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
if default_font.is_some() {
doc.styles.default_chr.font = default_font;
Arc::make_mut(&mut doc.styles).default_chr.font = default_font;
}
Ok(doc)
}
+69 -1
View File
@@ -1229,6 +1229,40 @@ fn a_code_paragraph_round_trips_without_a_direct_code_font() {
assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}");
}
/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862).
#[test]
fn escaped_semicolons_in_a_font_name_stop_at_the_limit() {
let mut r = reader::Reader::default();
r.st.dest = state::Dest::FontTbl;
for _ in 0..100_000 {
r.ch(';');
}
assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1);
let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1));
let runs = text_runs(rtf.as_bytes());
assert_eq!(run_fmt(&runs, "x").font, None);
}
#[test]
fn a_semicolon_in_a_font_name_round_trips() {
// `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740).
let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")];
let mut p = Para::default();
for (t, font) in fonts {
p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() });
}
let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() };
let rtf = export(&model::to_doc(&flow));
assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}");
let runs = text_runs(rtf.as_bytes());
for (t, font) in fonts {
assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}");
}
// A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it.
let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}");
assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D")));
}
#[test]
fn a_stylesheet_entry_without_a_number_is_normal() {
// An entry without `\sN` is style 0; it doesn't replace the entry before it.
@@ -1558,8 +1592,42 @@ fn hyperlink_field_targets() {
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
assert_eq!(hyperlink(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
assert_eq!(hyperlink(r#"Hyperlink "http://example.com""#), Some("http://example.com".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/pathlatest".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
// A tooltip or frame before the address, a longer field name, escapes and the writer's `%22`.
assert_eq!(hyperlink(r#"HYPERLINK \o "tip" \t "_blank" "http://example.com""#), Some("http://example.com".into()));
assert_eq!(hyperlink(r#"HYPERLINKX "http://example.com""#), None);
assert_eq!(hyperlink(r#"HYPERLINK "C:\\Docs\\f.rtf""#), Some(r"C:\Docs\f.rtf".into()));
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/a%22b""#), Some(r#"http://example.com/a"b"#.into()));
}
/// A link with a quote in it survives export and import: the writer stores it as `%22`.
#[test]
fn a_quote_in_a_link_round_trips() {
let rtf = br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "http://a.b/x%22y"}}{\fldrslt{link}}}\par}"#;
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
let d = import(rtf).unwrap();
assert_eq!(link(&d).as_deref(), Some(r#"http://a.b/x"y"#));
let exported = export(&d);
assert!(exported.contains("x%22y"), "{exported}");
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(r#"http://a.b/x"y"#));
}
/// Backslashes in a link survive export and import, a trailing one and two in a row too.
#[test]
fn backslashes_in_a_link_round_trip() {
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
for target in [r"docs\", r"a\\b", r"..\up\f.doc"] {
let mut d = import(br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "x"}}{\fldrslt{link}}}\par}"#).unwrap();
for b in d.body.iter_mut() {
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
p.runs.iter_mut().filter(|r| r.props.link.is_some()).for_each(|r| r.props.link = Some(target.into()));
}
}
assert_eq!(link(&d).as_deref(), Some(target));
let exported = export(&d);
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(target), "{exported}");
}
}
#[test]
+19 -5
View File
@@ -11,7 +11,7 @@ fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding>
impl Reader {
pub(super) fn commit_font(&mut self) {
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
let name = std::mem::take(&mut self.font_name).trim().to_string();
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
&& self.fonts.len() < 4096
{
@@ -36,6 +36,11 @@ impl Reader {
self.skip_chars -= 1;
return;
}
// `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`).
if b == b';' && self.st.dest == Dest::FontTbl {
self.ch(';');
return;
}
let cp = self.code_page();
let Some(encoding) = multibyte_encoding(cp) else {
self.finish_multibyte();
@@ -106,10 +111,11 @@ impl Reader {
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
/// BOM and the rest decoded as UTF-8.
pub(super) fn run(&mut self, chunk: &[u8]) {
if self.skip_chars > 0 {
for b in chunk {
self.byte(*b);
}
// The fallback bytes after `\uN` are skipped; the rest of the run is text like any other.
let skip = self.skip_chars.min(chunk.len());
self.skip_chars -= skip;
let chunk = chunk.get(skip..).unwrap_or_default();
if chunk.is_empty() {
return;
}
let cp = self.code_page();
@@ -302,6 +308,14 @@ impl Reader {
return;
}
self.finish_multibyte();
// Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it.
if c == ';' && self.st.dest == Dest::FontTbl {
// Held to the limit as other characters are; one past it drops the name.
if self.font_name.len() <= model::MAX_FONT_NAME {
self.font_name.push(c);
}
return;
}
let mut buf = [0u8; 4];
self.text(c.encode_utf8(&mut buf));
}
+2 -71
View File
@@ -507,76 +507,7 @@ impl Reader {
}
}
/// The target of a `HYPERLINK "url"` (or `HYPERLINK "url" \l "bookmark"`, `HYPERLINK \l "bookmark"`) field instruction.
/// The target of a `HYPERLINK` field instruction; `%22` is a quote, as the writer stores one.
pub(super) fn hyperlink(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[9..].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[start..i]);
continue;
}
let arg = if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let val = &rest[start..i];
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[start..i].to_string()
};
let clean_arg = arg.replace("%22", "\"");
if switch.take() == Some("\\l") {
local = Some(clean_arg);
} else 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,
}
wordcraft_doc::link::hyperlink_target(instr).map(|t| t.replace("%22", "\""))
}
+6 -6
View File
@@ -38,8 +38,8 @@ fn sample() -> Document {
p.insert_text(n, ".", &CharProps::default()).unwrap();
blocks.push(para_block(p));
blocks.push(para_block(Paragraph::with_text("Second Level", CharProps::default()).styled("Heading2")));
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
for (t, nm, lvl) in [("first bullet", bul, 0u8), ("second bullet", bul, 0), ("nested bullet", bul, 1)] {
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
q.props.numbering = Some(wordcraft_doc::props::NumRef { num: nm, level: lvl });
@@ -166,8 +166,8 @@ fn check_round_trip(ext: &str, rich: bool) {
/// Two bullets directly followed by two numbered items.
fn bullets_then_numbers() -> Document {
let mut d = Document::new();
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
let mut blocks = Vec::new();
for (t, nm) in [("dot one", bul), ("dot two", bul), ("num one", num), ("num two", num)] {
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
@@ -198,7 +198,7 @@ fn numbered_list_after_bullets_keeps_its_kind() {
fn markdown_list_items_that_start_like_blocks_keep_their_text() {
for text in ["---", "--", "1. Intro", "# Agenda", "ordinary item"] {
let mut d = Document::new();
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let mut p = Paragraph::with_text(text, CharProps::default()).styled("ListParagraph");
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
d.body = vec![para_block(p)];
@@ -365,7 +365,7 @@ fn txt_export_writes_symbol_font_text_as_unicode() {
#[test]
fn txt_export_keeps_list_labels() {
let mut d = Document::new();
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
let mut p = Paragraph::with_text("alpha", CharProps::default());
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
d.body = vec![para_block(p), para_block(Paragraph::with_text("plain", CharProps::default()))];
+5 -2
View File
@@ -44,6 +44,9 @@ const MAX_PARENT_CHAIN: usize = 64;
/// Path given to the synthetic author-name paragraphs: no paragraph of the comment's story has it.
const HEADER_PATH: u32 = u32::MAX;
#[cfg(test)]
mod anchor_tests;
/// One comment's balloon before it is positioned: items relative to the card's top-left.
struct Card {
page: usize,
@@ -74,8 +77,8 @@ pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
let mut out = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for off in p.object_offsets() {
let Some(InlineObject::CommentStart { id }) = p.object_at(off) else { continue };
for (off, object) in p.objects_with_offsets() {
let InlineObject::CommentStart { id } = object else { continue };
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
continue;
}
@@ -0,0 +1,75 @@
use super::*;
use wordcraft_doc::{Block, Comment, Path, Table, para_block};
fn push(p: &mut Paragraph, object: InlineObject) {
p.insert_object(p.len(), object, &CharProps::default()).unwrap();
}
#[test]
fn anchors_keep_first_root_occurrences_and_utf8_table_positions() {
let mut doc = Document::from_text("Préface Ω\nSecond");
for (id, parent) in [(40, None), (7, Some(40)), (9, None), (2, None), (3, None)] {
doc.comments.insert(id, Comment { parent, ..Default::default() });
}
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
push(p, InlineObject::BookmarkStart { name: "unrelated".into() });
push(p, InlineObject::CommentStart { id: 999 }); // no such comment
push(p, InlineObject::CommentStart { id: 7 }); // reply, not its own balloon
push(p, InlineObject::CommentEnd { id: 9 });
let first = p.len();
push(p, InlineObject::CommentStart { id: 40 });
push(p, InlineObject::CommentStart { id: 40 }); // first occurrence wins
let second = p.len();
push(p, InlineObject::CommentStart { id: 9 });
let mut table = Table::new(1, 1, 200.0);
let mut cell = Paragraph::with_text("Cell λ", CharProps::default());
let in_cell = cell.len();
push(&mut cell, InlineObject::CommentStart { id: 2 });
table.rows[0].cells[0].blocks = vec![para_block(cell)];
doc.body.push(std::sync::Arc::new(Block::Table(table)));
// Comments in other stories do not acquire body balloons.
let mut note = Paragraph::with_text("Note", CharProps::default());
push(&mut note, InlineObject::CommentStart { id: 3 });
doc.add_part(wordcraft_doc::PartKind::Footnote, vec![para_block(note)]);
assert_eq!(
anchors(&doc),
vec![(40, Pos::body(0, first)), (9, Pos::body(0, second)), (2, Pos::new(StoryRef::Body, Path(vec![2, 0, 0, 0]), in_cell))]
);
}
fn dense_markers(n: usize) -> Document {
let mut doc = Document::new();
doc.comments.insert(0, Comment::default());
let mut p = Paragraph::with_text("x", CharProps::default());
p.text = wordcraft_doc::para::OBJ.to_string().repeat(n);
p.runs[0].len = p.len();
p.objects = vec![InlineObject::BookmarkEnd { name: "unrelated".into() }; n];
push(&mut p, InlineObject::CommentStart { id: 0 });
doc.body = vec![para_block(p)];
doc
}
#[test]
fn anchors_walk_dense_unrelated_markers_in_linear_work() {
const N: usize = 2_000;
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
}
#[test]
fn anchors_keep_the_existing_limit_in_document_order() {
let mut doc = Document::new();
let mut p = Paragraph::with_text("x", CharProps::default());
p.text = wordcraft_doc::para::OBJ.to_string().repeat(MAX_BALLOONS + 1);
p.runs[0].len = p.len();
p.objects = (0..=MAX_BALLOONS).map(|id| InlineObject::CommentStart { id: id as u32 }).collect();
doc.comments = (0..=MAX_BALLOONS).map(|id| (id as u32, Comment::default())).collect();
doc.body = vec![para_block(p)];
let got = anchors(&doc);
assert_eq!(got.len(), MAX_BALLOONS);
assert_eq!(got.first(), Some(&(0, Pos::body(0, 0))));
assert_eq!(got.last(), Some(&((MAX_BALLOONS - 1) as u32, Pos::body(0, (MAX_BALLOONS - 1) * wordcraft_doc::para::OBJ.len_utf8()))));
}
+17 -10
View File
@@ -421,19 +421,20 @@ impl DocLayout {
}
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
/// headers/footers). A text box wins inside its area, except one behind the text, which only
/// wins where no body text is.
/// headers/footers). A text box wins inside its area (its rotated area when it is turned, as
/// [`DocLayout::object_at`] takes it), except one behind the text, which only wins where no
/// body text is.
pub fn story_at(&self, page: usize, x: f32, y: f32) -> Option<StoryRef> {
let p = self.pages.get(page)?;
let text_box = |behind: bool| {
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(Point::new(x, y))).and_then(|o| o.text_box)
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(o.unspin(x, y))).and_then(|o| o.text_box)
};
text_box(false).map(StoryRef::Part).or_else(|| text_at(p, page, x, y)).or_else(|| text_box(true).map(StoryRef::Part))
}
/// The object a press at (x, y) grabs: a picture or shape anywhere on it, a text box on its
/// border (within `edge` points either side; inside is its text). Topmost first; objects
/// behind the text only where there's no text.
/// border (within `edge` points either side; inside is its text, whatever lies under the
/// box). Topmost first; objects behind the text only where there's no text.
pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
self.object_at_in(page, x, y, edge, StoryRef::Body)
}
@@ -456,11 +457,17 @@ impl DocLayout {
StoryRef::Body => text_at(p, page, x, y).is_some(),
StoryRef::Part(id) => hf_text_at(p, id, x, y),
};
cands
.iter()
.find(|o| !o.behind() && grabs(o))
.or_else(|| if text_here { None } else { cands.iter().find(|o| o.behind() && grabs(o)) })
.cloned()
// Inside a text box is its text: a press there grabs nothing, not an object under the box
// (a form scanned as a picture, with text boxes laid over it to fill in). The same holds
// among the objects behind the text.
let inside_text_box = |o: &ObjectHit| o.text_box.is_some() && !grabs(o) && o.rect.contains(o.unspin(x, y));
let topmost = |behind: bool| cands.iter().find(|o| o.behind() == behind && (grabs(o) || inside_text_box(o)));
match topmost(false) {
Some(o) if inside_text_box(o) => None,
Some(o) => Some(o.clone()),
None if text_here => None,
None => topmost(true).filter(|o| !inside_text_box(o)).cloned(),
}
}
/// The text box in the header or footer at (x, y) on `page`.
+48 -7
View File
@@ -1,9 +1,13 @@
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
//! on top of the Unicode line-break opportunities (UAX #14).
//!
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
//! - Kinsoku on (the default): no ordinary break opportunity falls before closing East Asian
//! punctuation (、。,)」…), a small kana (っゃゕヶ), the prolonged sound mark (ー ー) or an
//! iteration mark, and none falls after opening punctuation ((「【…). UAX #14 already forbids
//! most of these breaks, and classes the small kana and the prolonged sound mark CJ/NS, whose
//! break is only a tailoring choice; the lists make sure of it for the standard Japanese/Chinese
//! sets. A line too narrow for any opportunity still takes the emergency break in
//! `para::break_lines`, which can put one of these characters at the start of the line.
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
//! - Word wrap off: Latin words may break at any character.
//! - Automatic spacing (`autoSpaceDE`, `autoSpaceDN`): which neighbouring characters get room
@@ -14,10 +18,15 @@
use unicode_segmentation::UnicodeSegmentation;
/// East Asian characters a line may not start with: closing brackets and quotes, the
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
/// iteration and sound marks.
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
/// 行頭禁則: East Asian characters a line may not start with, grouped as JIS X 4051 §4 and
/// Word's default 禁則処理 do:
/// - 小書きの仮名 (small kana): hiragana `ぁぃぅぇぉっゃゅょゎゕゖ`, katakana `ァィゥェォッャュョヮヵヶ`,
/// the katakana phonetic extensions (U+31F0…U+31FF) and the halfwidth small kana `ァィゥェォャュョッ`;
/// - 長音記号 (the prolonged sound mark): `ー` and its halfwidth form `ー`;
/// - 繰り返し記号 (iteration marks, with the sound marks): `゛゜ヽヾゝゞ々〻゙゚`;
/// - 閉じ括弧類 and the rest: closing brackets and quotes, the ideographic comma and full stop,
/// the fullwidth and halfwidth punctuation, the middle dot, `%` and `¢`.
const NO_START: &str = "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー゛゜ヽヾゝゞ々〻゙゚、。,.・:;?!〉》」』】〕〗〙〛〞〟)]}⦆。」、・%¢";
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
@@ -149,4 +158,36 @@ mod tests {
let any = breaks("Hello café", true, false);
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
}
#[test]
fn kinsoku_no_line_starts_with_a_small_kana_or_the_prolonged_sound_mark() {
// A break opportunity sits right before each of these (after a kanji, or after a 、);
// JIS X 4051 §4 行頭禁則 drops it, as Word's default 禁則処理 does.
let ja = "漢っ、ゃ漢ー漢ッ漢ャ漢ゕ漢ヶ漢ㇺ漢ッ漢ャ漢ー";
let non_starters = ["っ", "ゃ", "ー", "ッ", "ャ", "ゕ", "ヶ", "ㇺ", "ッ", "ャ", "ー"];
let on = breaks(ja, true, true);
for c in non_starters {
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
}
// Kinsoku off: a line may break between any two East Asian characters, these included.
let off = breaks(ja, false, true);
for c in non_starters {
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
}
// Text with no East Asian characters breaks exactly as before either way.
let en = "Hello wonderful (world), 50%.";
assert_eq!(breaks(en, true, true), breaks(en, false, true));
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
}
/// 行頭禁則: the small kana and the prolonged sound mark are CJ/NS in UAX #14, where the break
/// before them is forbidden only optionally — so the list itself must hold every one of them.
#[test]
fn no_start_holds_every_small_kana_and_the_prolonged_sound_mark() {
for c in "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー".chars()
{
assert!(super::is_east_asian(c), "U+{:04X} {c} is East Asian text", c as u32);
assert!(super::NO_START.contains(c), "NO_START (行頭禁則) must hold U+{:04X} {c}", c as u32);
}
}
}
+2
View File
@@ -50,6 +50,7 @@ mod notes;
mod page;
mod pass;
mod placed;
mod substituted;
mod table_flow;
#[cfg(test)]
@@ -78,6 +79,7 @@ use page::item_bottom;
use pass::layout_pass;
pub use pass::now_ms;
pub use placed::{HfSlot, Placed, turn_point, turn_rect, unturn_point};
pub use substituted::MAX_SUBSTITUTED;
/// How the document is viewed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
+4
View File
@@ -32,6 +32,7 @@ pub(crate) use builder::left_out;
#[cfg(test)]
use chars::{MISSING_ROMAN_FONT, lookup_family};
pub(crate) use chars::{caps_char, push_caps};
pub use chars::{draws_something, east_asian_substitute_face, reported_font_name, substitute_face};
#[cfg(test)]
pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
pub use grid::{Exclusion, Grid, is_east_asian};
@@ -47,6 +48,9 @@ pub use proof::{ProofRun, is_list_item, paragraph_issues, proof_runs, proof_text
#[derive(Clone, Debug)]
pub struct ParaLayout {
pub rp: ResolvedPara,
/// Fonts the shown text (and list label) asks for that aren't installed, each with a face
/// it was drawn in instead: a substitute or a fallback face covering its characters.
pub substituted: Vec<(String, FaceRef)>,
pub styles: Vec<StyleRun>,
pub glyphs: Vec<Glyph>,
pub clusters: Vec<Cluster>,

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