mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 12:27:11 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc41918814 | ||
|
|
04f6f53b8a | ||
|
|
ff15dc451d | ||
|
|
aa113f0801 | ||
|
|
e8482fa964 | ||
|
|
85a3a53a8e | ||
|
|
2dcde62220 | ||
|
|
5b92d61a0c | ||
|
|
9fd3490e4c | ||
|
|
bf9b84f10b | ||
|
|
c3d45adeab | ||
|
|
99fa307cf2 | ||
|
|
2ed77831f9 | ||
|
|
d6723153cc | ||
|
|
408ae56f49 | ||
|
|
cd5b1a5077 | ||
|
|
15127fb53d | ||
|
|
196f828f3f | ||
|
|
1e48e6710f | ||
|
|
f39f4b2455 | ||
|
|
d74ba89483 | ||
|
|
65b66c6216 | ||
|
|
927a305943 | ||
|
|
0837c8b7e0 | ||
|
|
dcef934078 | ||
|
|
4fe9891572 | ||
|
|
aa91e46753 | ||
|
|
fd152762b3 | ||
|
|
2689f3c6a3 | ||
|
|
09b8b436cc | ||
|
|
f322c60bdf | ||
|
|
46d5554f39 | ||
|
|
d8cfe59c14 | ||
|
|
d04d916764 | ||
|
|
159b84f54c | ||
|
|
fbacffd282 | ||
|
|
dbfa2af68a | ||
|
|
b9899f410b | ||
|
|
b1d0aab486 | ||
|
|
a381c93e8d | ||
|
|
5baaf97413 | ||
|
|
8826025f18 | ||
|
|
e989fc2b3d | ||
|
|
591cbb6f68 | ||
|
|
7af79cd9f0 | ||
|
|
0f9dc5c6ae | ||
|
|
9d1e207c78 | ||
|
|
be7474566b | ||
|
|
1350cb959b | ||
|
|
36af036233 | ||
|
|
f0d47b6090 | ||
|
|
a38caa4b15 | ||
|
|
1d0dff03a4 | ||
|
|
9644e646a6 | ||
|
|
8889e356d5 | ||
|
|
98c457a98d |
@@ -0,0 +1,73 @@
|
||||
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
|
||||
# stay on their existing nightly/release schedule.
|
||||
name: Printing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
- '.github/workflows/printing.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
- '.github/workflows/printing.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: printing-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
native:
|
||||
name: Print (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Native app and UI Clippy
|
||||
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Native app and UI tests
|
||||
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
|
||||
|
||||
- name: Browser compatibility
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
|
||||
@@ -13,6 +13,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
|
||||
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
|
||||
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
|
||||
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
|
||||
Generated
+38
-20
@@ -843,6 +843,22 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
|
||||
dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -5082,10 +5098,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5113,7 +5130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
@@ -5125,11 +5142,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -5141,7 +5158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
@@ -5156,7 +5173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -5178,7 +5195,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5204,7 +5221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
@@ -5217,7 +5234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5230,14 +5247,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5256,7 +5273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -5268,7 +5285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -5276,7 +5293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
@@ -5296,7 +5313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -5305,7 +5322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -5319,8 +5336,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
@@ -5343,7 +5361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5362,7 +5380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -5475,7 +5493,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -52,6 +52,27 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
Copyright (C) 2008-2011 Claudio Beccari
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
|
||||
+37
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
@@ -133,6 +135,20 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
|
||||
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
|
||||
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
|
||||
| 2026-10-12 | Sample-text formulas on Enter (#1067): `=rand()` and `=lorem()`, optional paragraph/sentence counts, 5/3 defaults, original English and one-step undo |
|
||||
| 2026-10-12 | File › Export › Word 2003 XML Document (#1081): documents save as WordprocessingML 2003 (`format: "wordml2003"` on the save commands, `wordcraft-cli convert --format wordml2003`); shapes, text boxes, charts and SmartArt don't survive, and it hasn't been opened in Word yet |
|
||||
| 2026-10-12 | Direct run-level native InkML (`w:contentPart`) now survives DOCX edits and repeated saves with its relationship, content type and bytes intact. Native InkML rendering/editing and ink nested in groups or canvases remain missing |
|
||||
| 2026-10-13 | The caret is black or white, whichever shows against what lies under it — the paper, a page colour, a dark page, shading or a highlight — whatever the UI theme, as Word's inverted caret does; before, it stayed black on dark page colours and highlights and could vanish (#299) |
|
||||
| 2026-10-13 | Clicking the ribbon's tab names, empty space, gallery tiles or View check boxes, or the status bar's view buttons and language menu, no longer makes the caret vanish: typing goes on in the document as in Word, without another click on the page (#1093) |
|
||||
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
|
||||
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
|
||||
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
|
||||
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
|
||||
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
|
||||
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
|
||||
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
|
||||
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
|
||||
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
|
||||
@@ -144,6 +160,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
|
||||
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
|
||||
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
|
||||
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
|
||||
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
|
||||
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
|
||||
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
|
||||
@@ -164,17 +181,21 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
|
||||
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
|
||||
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
|
||||
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
|
||||
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
|
||||
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
|
||||
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
|
||||
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
|
||||
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
|
||||
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
|
||||
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
|
||||
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
|
||||
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
|
||||
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
|
||||
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
|
||||
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
|
||||
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
|
||||
@@ -196,6 +217,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
|
||||
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
|
||||
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
|
||||
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
|
||||
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
|
||||
@@ -265,6 +287,15 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
|
||||
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
|
||||
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
|
||||
| 2026-10-12 | trivial | Progress log: direct run-level native InkML content parts survive DOCX edits and repeated saves |
|
||||
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
|
||||
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
|
||||
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
|
||||
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
|
||||
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
|
||||
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
|
||||
| 2026-10-11 | trivial | Progress log: the table context menu |
|
||||
@@ -272,11 +303,15 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
|
||||
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
|
||||
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
|
||||
| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) |
|
||||
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
|
||||
| 2026-10-12 | trivial | Progress log: ribbon groups fit above their labels |
|
||||
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
|
||||
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
|
||||
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
|
||||
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
|
||||
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
|
||||
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
|
||||
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
|
||||
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
|
||||
@@ -293,6 +328,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
|
||||
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
|
||||
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) |
|
||||
| 2026-10-11 | trivial | Progress log: Outline view (#493) |
|
||||
|
||||
@@ -14,8 +14,10 @@ const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
|
||||
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
--markup (Print Markup): comments beside each page and
|
||||
tracked changes in pdf/png, a comment list in html/md/txt
|
||||
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
|
||||
@@ -50,6 +52,12 @@ fn open(path: &str) -> Result<Session, String> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
|
||||
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
|
||||
Ok(String::from_utf8_lossy(&bytes).replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
|
||||
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
|
||||
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
|
||||
@@ -105,7 +113,7 @@ fn automation_roots(
|
||||
}
|
||||
|
||||
const NO_OPTIONS: &[OptionSpec] = &[];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
|
||||
const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--file", takes_value: true },
|
||||
@@ -180,13 +188,26 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
match cmd {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
let (i, o) = (pos(0)?, pos(1)?);
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths: Vec<&String> = rest
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let missing = || format!("missing argument\n\n{USAGE}");
|
||||
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let mut s = open(&i)?;
|
||||
let r = if o.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
|
||||
} else {
|
||||
s.run("file.save", &json!({"path": o, "markup": markup}))
|
||||
let mut params = json!({"path": o, "markup": markup});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
@@ -201,7 +222,8 @@ fn run(args: &[String]) -> Result<(), Failure> {
|
||||
"text" => {
|
||||
validate_options("text", &rest, NO_OPTIONS)?;
|
||||
let s = open(&pos(0)?)?;
|
||||
write_stdout(|o| writeln!(o, "{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body)))
|
||||
let text = listed_text(&s.doc)?;
|
||||
write_stdout(|o| writeln!(o, "{text}"))
|
||||
}
|
||||
"inspect" => {
|
||||
validate_options("inspect", &rest, NO_OPTIONS)?;
|
||||
@@ -459,6 +481,12 @@ mod tests {
|
||||
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_bullet_and_number_labels() {
|
||||
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
|
||||
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_flag_names_the_headless_session() {
|
||||
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
|
||||
|
||||
@@ -9,7 +9,7 @@ use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
mod font_store;
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
|
||||
@@ -54,6 +54,8 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
wl-clipboard-rs = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
open = "5"
|
||||
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
|
||||
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
|
||||
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
|
||||
@@ -65,6 +67,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
@@ -74,6 +77,3 @@ pollster = "0.4"
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
# The control server's random key.
|
||||
getrandom = "0.3"
|
||||
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
|
||||
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
|
||||
open = "5.4"
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
|
||||
//!
|
||||
//! Everything read here comes from other programs: sizes are capped as it is read.
|
||||
//!
|
||||
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
|
||||
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
|
||||
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
|
||||
//! with OMML, #1297) it can't, so they aren't offered.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
@@ -16,6 +21,35 @@ fn open() -> Option<arboard::Clipboard> {
|
||||
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
|
||||
/// (the caller copies the text alone), including a Wayland session without the data-control
|
||||
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
|
||||
pub fn set_html(html: &str, text: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
WRITER.with(|w| {
|
||||
let Ok(mut slot) = w.try_borrow_mut() else { return false };
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
*slot = None;
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The clipboard's text.
|
||||
pub fn text() -> Option<String> {
|
||||
open()?.get_text().ok()
|
||||
@@ -103,6 +137,15 @@ mod platform {
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
|
||||
pub fn can_set() -> bool {
|
||||
!wayland()
|
||||
|| !matches!(
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
|
||||
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
@@ -146,6 +189,10 @@ mod platform {
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
|
||||
@@ -161,4 +208,8 @@ mod platform {
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
|
||||
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
|
||||
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
|
||||
/// are ignored, as the XDG Base Directory spec says.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
|
||||
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let absolute = |s: &&str| Path::new(s).is_absolute();
|
||||
@@ -130,6 +130,7 @@ mod xdg {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
@@ -163,6 +164,7 @@ mod tests {
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
let p = |s: &str| PathBuf::from(s);
|
||||
|
||||
@@ -54,7 +54,7 @@ impl eframe::App for App {
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if closing || self.0.quit_requested {
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
|
||||
// the close is allowed, take that path instead of dropping the window on this turn.
|
||||
@@ -78,6 +78,7 @@ impl eframe::App for App {
|
||||
self.3.show(frame);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(&self.0);
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
@@ -95,11 +96,16 @@ impl eframe::App for App {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
}
|
||||
static QUIT: AtomicBool = AtomicBool::new(false);
|
||||
if QUIT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
@@ -193,18 +199,20 @@ fn save_prefs(app: &WordApp) {
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system's PDF viewer (`print`, #15), the system clipboard's text, formatted copies,
|
||||
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
|
||||
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
|
||||
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
print: Some(print::hook()),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
clipboard_picture: Some(Box::new(clipboard::picture)),
|
||||
make_default_app: default_apps::hook(),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
|
||||
+63
-216
@@ -1,229 +1,76 @@
|
||||
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
|
||||
//!
|
||||
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
|
||||
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
|
||||
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
|
||||
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
|
||||
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
|
||||
//! prints from there through the system print dialog.
|
||||
//!
|
||||
//! The viewer reads the file after we return, so it can't be removed right away: files older than
|
||||
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
|
||||
//!
|
||||
//! [`Services::print`]: wordcraft_ui_egui::Services::print
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Temporary print files start with this and end with `.pdf`.
|
||||
const PREFIX: &str = "wordcraft-print-";
|
||||
/// How long a print file is kept for its viewer before the clean-up removes it.
|
||||
const KEEP: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// The `Services::print` hook.
|
||||
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
|
||||
|
||||
/// The desktop print hook; also clears print files left over from earlier runs.
|
||||
pub fn hook() -> Hook {
|
||||
let dir = std::env::temp_dir();
|
||||
spawn_cleanup(dir.clone());
|
||||
Box::new(move |bytes| {
|
||||
let r = print_with(&dir, bytes, open_viewer);
|
||||
spawn_cleanup(dir.clone());
|
||||
r
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
|
||||
/// is removed again.
|
||||
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
|
||||
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
|
||||
open(&path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit kill PowerShell/CUPS before it hands the job to the spooler.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings, collate| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
printer: Some(settings.printer),
|
||||
copies: settings.copies.clamp(1, 999),
|
||||
collate,
|
||||
duplex: settings.duplex.map(|value| match value {
|
||||
Duplex::LongEdge => craft_print::Duplex::LongEdge,
|
||||
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
|
||||
Duplex::Off => craft_print::Duplex::Off,
|
||||
}),
|
||||
grayscale: settings.grayscale,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "WordCraft document".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
receiver
|
||||
})
|
||||
}
|
||||
|
||||
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
|
||||
/// follows a link at) an existing path; readable by the user only where the OS supports it.
|
||||
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
|
||||
for _ in 0..16 {
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
return match file.write_all(bytes) {
|
||||
Ok(()) => Ok(path),
|
||||
Err(e) => {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
|
||||
}
|
||||
|
||||
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
|
||||
/// Other files, links and files it may not remove are left alone.
|
||||
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut removed = 0;
|
||||
for entry in entries.flatten() {
|
||||
if !is_print_file(&entry.file_name().to_string_lossy()) {
|
||||
continue;
|
||||
}
|
||||
// `DirEntry::metadata` doesn't follow links.
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
|
||||
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn is_print_file(name: &str) -> bool {
|
||||
name.starts_with(PREFIX) && name.ends_with(".pdf")
|
||||
}
|
||||
|
||||
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
|
||||
fn spawn_cleanup(dir: PathBuf) {
|
||||
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
|
||||
let n = remove_stale(&dir, SystemTime::now(), KEEP);
|
||||
if n > 0 {
|
||||
log::info!("removed {n} old print file(s)");
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("print clean-up: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_viewer(path: &Path) -> Result<(), String> {
|
||||
let mut child =
|
||||
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
|
||||
let path = path.to_path_buf();
|
||||
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
|
||||
if !child.wait().is_ok_and(|s| s.success())
|
||||
&& let Err(e) = open::that(&path)
|
||||
{
|
||||
log::error!("print: couldn't open {}: {e}", path.display());
|
||||
}
|
||||
});
|
||||
if let Err(e) = waiter {
|
||||
log::warn!("print: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
|
||||
/// the file association on Windows).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn open_viewer(path: &Path) -> Result<(), String> {
|
||||
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Services, WordApp};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
|
||||
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
|
||||
/// start is reported in the status bar and leaves no file behind.
|
||||
#[test]
|
||||
fn file_print_opens_a_pdf_file() {
|
||||
let dir = temp_dir("open");
|
||||
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
|
||||
let seen = opened.clone();
|
||||
let hook_dir = dir.clone();
|
||||
let services = Services {
|
||||
print: Some(Box::new(move |bytes| {
|
||||
print_with(&hook_dir, bytes, |path| {
|
||||
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
app.run("text.insert", json!({"text": "Print me"})).unwrap();
|
||||
app.run("file.print", json!({})).unwrap();
|
||||
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
|
||||
app.run("ui.print", json!({})).unwrap();
|
||||
app.run("ui.print", json!({})).unwrap();
|
||||
|
||||
let opened = opened.borrow();
|
||||
assert_eq!(opened.len(), 2, "each print opens the viewer once");
|
||||
for (path, bytes) in opened.iter() {
|
||||
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
|
||||
assert_eq!(path.parent(), Some(dir.as_path()));
|
||||
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
|
||||
}
|
||||
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
|
||||
|
||||
let mut app = WordApp::new(
|
||||
Session::new(wordcraft_doc::Document::new()),
|
||||
Services {
|
||||
print: Some(Box::new({
|
||||
let dir = dir.clone();
|
||||
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let e = app.run("ui.print", json!({})).unwrap_err();
|
||||
assert!(e.contains("no PDF viewer"), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The clean-up removes old print files only.
|
||||
#[test]
|
||||
fn stale_print_files_are_removed() {
|
||||
let dir = temp_dir("stale");
|
||||
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
|
||||
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
|
||||
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
|
||||
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
|
||||
let other = dir.join("notes.pdf");
|
||||
std::fs::write(&other, b"keep").unwrap();
|
||||
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
|
||||
|
||||
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
|
||||
assert!(fresh.exists() && other.exists() && !old.exists());
|
||||
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
fn shutdown_waits_for_pending_submission_workers() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let (completed, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
JOBS.lock().unwrap().push(worker);
|
||||
let shutdown = std::thread::spawn(finish_pending_jobs);
|
||||
assert!(completion.try_recv().is_err(), "the print worker is still pending");
|
||||
release.send(()).unwrap();
|
||||
shutdown.join().unwrap();
|
||||
completion.recv().unwrap();
|
||||
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
% title: Hyphenation patterns for Italian
|
||||
% copyright: Copyright (C) 2008-2011 Claudio Beccari
|
||||
% notice: This file is part of the hyph-utf8 package.
|
||||
% See http://www.hyphenation.org/tex for more information.
|
||||
% language:
|
||||
% name: Italian
|
||||
% tag: it
|
||||
% version: 4.9 2014/04/22
|
||||
% authors:
|
||||
% -
|
||||
% name: Claudio Beccari
|
||||
% contact: claudio.beccari (at) gmail.com
|
||||
% licence:
|
||||
% - This file is available under any of the following licences:
|
||||
% -
|
||||
% name: LPPL
|
||||
% version: 1.3
|
||||
% or_later: true
|
||||
% url: http://www.latex-project.org/lppl.txt
|
||||
% status: maintained
|
||||
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
|
||||
% -
|
||||
% name: MIT
|
||||
% url: https://opensource.org/licenses/MIT
|
||||
% text: >
|
||||
% Permission is hereby granted, free of charge, to any person
|
||||
% obtaining a copy of this software and associated documentation
|
||||
% files (the "Software"), to deal in the Software without
|
||||
% restriction, including without limitation the rights to use,
|
||||
% copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
% copies of the Software, and to permit persons to whom the
|
||||
% Software is furnished to do so, subject to the following
|
||||
% conditions:
|
||||
%
|
||||
% The above copyright notice and this permission notice shall be
|
||||
% included in all copies or substantial portions of the Software.
|
||||
%
|
||||
% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
% OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
% NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
% HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
% WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
% FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
% OTHER DEALINGS IN THE SOFTWARE.
|
||||
% hyphenmins:
|
||||
% typesetting:
|
||||
% left: 2
|
||||
% right: 2
|
||||
% changes:
|
||||
% - 2014-04-22 - Add few patterns involving `h'
|
||||
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
|
||||
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
|
||||
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
|
||||
% - 2008-03-08 - (last change in ithyph.tex)
|
||||
% texlive:
|
||||
% encoding: ascii
|
||||
% babelname: italian
|
||||
% legacy_patterns: ithyph.tex
|
||||
% message: Italian hyphenation patterns
|
||||
% description: |-
|
||||
% Hyphenation patterns for Italian in ASCII encoding.
|
||||
% Compliant with the Recommendation UNI 6461 on hyphenation
|
||||
% issued by the Italian Standards Institution
|
||||
% (Ente Nazionale di Unificazione UNI).
|
||||
% ==========================================
|
||||
%
|
||||
% These hyphenation patterns for the Italian language are supposed to comply
|
||||
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
|
||||
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
|
||||
% or declaration of fitness to any particular purpose is given and any
|
||||
% liability is disclaimed.
|
||||
%
|
||||
\patterns{
|
||||
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
|
||||
.anti1
|
||||
.anti3m2n
|
||||
.bio1
|
||||
.ca4p3s
|
||||
.circu2m1
|
||||
.contro1
|
||||
.di2s3cine
|
||||
.e2x1eu
|
||||
.fran2k3
|
||||
.free3
|
||||
.li3p2sa
|
||||
.narco1
|
||||
.opto1
|
||||
.orto3p2
|
||||
.para1
|
||||
.ph2l
|
||||
.ph2r
|
||||
.poli3p2
|
||||
.pre1
|
||||
.p2s
|
||||
.re1i2scr
|
||||
.sha2re3
|
||||
.tran2s3c
|
||||
.tran2s3d
|
||||
.tran2s3l
|
||||
.tran2s3n
|
||||
.tran2s3p
|
||||
.tran2s3r
|
||||
.tran2s3t
|
||||
.su2b3lu
|
||||
.su2b3r
|
||||
.wa2g3n
|
||||
.wel2t1
|
||||
2'2
|
||||
a1ia
|
||||
a1ie
|
||||
a1io
|
||||
a1iu
|
||||
a1uo
|
||||
a1ya
|
||||
2at.
|
||||
e1iu
|
||||
e2w
|
||||
o1ia
|
||||
o1ie
|
||||
o1io
|
||||
o1iu
|
||||
1b
|
||||
2bb
|
||||
2bc
|
||||
2bd
|
||||
2bf
|
||||
2bm
|
||||
2bn
|
||||
2bp
|
||||
2bs
|
||||
2bt
|
||||
2bv
|
||||
b2l
|
||||
b2r
|
||||
2b.
|
||||
2b'
|
||||
1c
|
||||
2cb
|
||||
2cc
|
||||
2cd
|
||||
2cf
|
||||
2ck
|
||||
2cm
|
||||
2cn
|
||||
2cq
|
||||
2cs
|
||||
2ct
|
||||
2cz
|
||||
2chh
|
||||
c2h
|
||||
2ch.
|
||||
2ch'.
|
||||
2ch''.
|
||||
2chb
|
||||
ch2r
|
||||
2chn
|
||||
c2l
|
||||
c2r
|
||||
2c.
|
||||
2c'
|
||||
.c2
|
||||
1d
|
||||
2db
|
||||
2dd
|
||||
2dg
|
||||
2dl
|
||||
2dm
|
||||
2dn
|
||||
2dp
|
||||
d2r
|
||||
2ds
|
||||
2dt
|
||||
2dv
|
||||
2dw
|
||||
2d.
|
||||
2d'
|
||||
.d2
|
||||
1f
|
||||
2fb
|
||||
2fg
|
||||
2ff
|
||||
2fn
|
||||
f2l
|
||||
f2r
|
||||
2fs
|
||||
2ft
|
||||
2f.
|
||||
2f'
|
||||
1g
|
||||
2gb
|
||||
2gd
|
||||
2gf
|
||||
2gg
|
||||
g2h
|
||||
g2l
|
||||
2gm
|
||||
g2n
|
||||
2gp
|
||||
g2r
|
||||
2gs
|
||||
2gt
|
||||
2gv
|
||||
2gw
|
||||
2gz
|
||||
2gh2t
|
||||
2g.
|
||||
2g'
|
||||
.h2
|
||||
1h
|
||||
2hb
|
||||
2hd
|
||||
2hh
|
||||
hi3p2n
|
||||
h2l
|
||||
2hm
|
||||
2hn
|
||||
2hr
|
||||
2hv
|
||||
2h.
|
||||
2h'
|
||||
.j2
|
||||
1j
|
||||
2j.
|
||||
2j'
|
||||
.k2
|
||||
1k
|
||||
2kg
|
||||
2kf
|
||||
k2h
|
||||
2kk
|
||||
k2l
|
||||
2km
|
||||
k2r
|
||||
2ks
|
||||
2kt
|
||||
2k.
|
||||
2k'
|
||||
1l
|
||||
2lb
|
||||
2lc
|
||||
2ld
|
||||
2l3f2
|
||||
2lg
|
||||
l2h
|
||||
l2j
|
||||
2lk
|
||||
2ll
|
||||
2lm
|
||||
2ln
|
||||
2lp
|
||||
2lq
|
||||
2lr
|
||||
2ls
|
||||
2lt
|
||||
2lv
|
||||
2lw
|
||||
2lz
|
||||
2l.
|
||||
2l'.
|
||||
2l''
|
||||
1m
|
||||
2mb
|
||||
2mc
|
||||
2mf
|
||||
2ml
|
||||
2mm
|
||||
2mn
|
||||
2mp
|
||||
2mq
|
||||
2mr
|
||||
2ms
|
||||
2mt
|
||||
2mv
|
||||
2mw
|
||||
2m.
|
||||
2m'
|
||||
1n
|
||||
2nb
|
||||
2nc
|
||||
2nd
|
||||
2nf
|
||||
2ng
|
||||
2nk
|
||||
2nl
|
||||
2nm
|
||||
2nn
|
||||
2np
|
||||
2nq
|
||||
2nr
|
||||
2ns
|
||||
n2s3fer
|
||||
2nt
|
||||
2nv
|
||||
2nz
|
||||
n2g3n
|
||||
2nheit
|
||||
2n.
|
||||
2n'
|
||||
1p
|
||||
2pd
|
||||
p2h
|
||||
p2l
|
||||
2pn
|
||||
3p2ne
|
||||
2pp
|
||||
p2r
|
||||
2ps
|
||||
3p2sic
|
||||
2pt
|
||||
2pz
|
||||
2p.
|
||||
2p'
|
||||
1q
|
||||
2qq
|
||||
2q.
|
||||
2q'
|
||||
1r
|
||||
2rb
|
||||
2rc
|
||||
2rd
|
||||
2rf
|
||||
r2h
|
||||
2rg
|
||||
2rk
|
||||
2rl
|
||||
2rm
|
||||
2rn
|
||||
2rp
|
||||
2rq
|
||||
2rr
|
||||
2rs
|
||||
2rt
|
||||
r2t2s3
|
||||
2rv
|
||||
2rx
|
||||
2rw
|
||||
2rz
|
||||
2r.
|
||||
2r'
|
||||
1s2
|
||||
2shm
|
||||
2sh.
|
||||
2sh'
|
||||
2s3s
|
||||
s4s3m
|
||||
2s3p2n
|
||||
2stb
|
||||
2stc
|
||||
2std
|
||||
2stf
|
||||
2stg
|
||||
2stm
|
||||
2stn
|
||||
2stp
|
||||
2sts
|
||||
2stt
|
||||
2stv
|
||||
2sz
|
||||
4s.
|
||||
4s'.
|
||||
4s''
|
||||
.t2
|
||||
1t
|
||||
2tb
|
||||
2tc
|
||||
2td
|
||||
2tf
|
||||
2tg
|
||||
t2h
|
||||
2th.
|
||||
t2l
|
||||
2tm
|
||||
2tn
|
||||
2tp
|
||||
t2r
|
||||
t2s
|
||||
3t2sch
|
||||
2tt
|
||||
t2t3s
|
||||
2tv
|
||||
2tw
|
||||
t2z
|
||||
2tzk
|
||||
tz2s
|
||||
2t.
|
||||
2t'.
|
||||
2t''
|
||||
1v
|
||||
2vc
|
||||
v2l
|
||||
v2r
|
||||
2vv
|
||||
2v.
|
||||
2v'.
|
||||
2v''
|
||||
1w
|
||||
w2h
|
||||
wa2r
|
||||
2w1y
|
||||
2w.
|
||||
2w'
|
||||
1x
|
||||
2xb
|
||||
2xc
|
||||
2xf
|
||||
2xh
|
||||
2xm
|
||||
2xp
|
||||
2xt
|
||||
2xw
|
||||
2x.
|
||||
2x'
|
||||
y1ou
|
||||
y1i
|
||||
1z
|
||||
2zb
|
||||
2zd
|
||||
2zl
|
||||
2zn
|
||||
2zp
|
||||
2zt
|
||||
2zs
|
||||
2zv
|
||||
2zz
|
||||
2z.
|
||||
2z'.
|
||||
2z''
|
||||
.z2
|
||||
} % Pattern end
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.lokinmodar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -64,9 +67,15 @@ sync_github_name = false
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
[people.edurbs]
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
[people.manucarcor]
|
||||
real_name = "Manuel Carmona"
|
||||
display_name = "manucarcor"
|
||||
sync_github_name = false
|
||||
|
||||
[people.odedigo]
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
+19
-1
@@ -23,9 +23,27 @@ pub struct Fragment {
|
||||
|
||||
impl Fragment {
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.to_document().plain_text(crate::StoryRef::Body)
|
||||
}
|
||||
/// The fragment as the body of a new document (default styles).
|
||||
pub fn to_document(&self) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.plain_text(crate::StoryRef::Body)
|
||||
d
|
||||
}
|
||||
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
|
||||
/// system clipboard as MathML (#816).
|
||||
pub fn has_equation(&self) -> bool {
|
||||
let mut found = false;
|
||||
for b in &self.blocks {
|
||||
each_para(b, 0, &mut |p| found |= p.objects.iter().any(|o| matches!(o, InlineObject::Equation { .. })));
|
||||
}
|
||||
found
|
||||
}
|
||||
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
|
||||
/// (Wingdings…) as their Unicode equivalents.
|
||||
pub fn clipboard_text(&self) -> String {
|
||||
self.to_document().shown_text(crate::StoryRef::Body)
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
|
||||
+176
-1
@@ -1,4 +1,6 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -14,3 +16,176 @@ pub fn cp1252(b: u8) -> char {
|
||||
b as char
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP874: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
|
||||
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
|
||||
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
|
||||
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
|
||||
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
|
||||
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
|
||||
0xFFFD, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
|
||||
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
|
||||
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
|
||||
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
|
||||
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
|
||||
0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1251: [u16; 128] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
|
||||
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
|
||||
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
|
||||
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
|
||||
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
|
||||
0x044E, 0x044F,
|
||||
];
|
||||
|
||||
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1253: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
|
||||
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
|
||||
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
|
||||
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
|
||||
0x03CE, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1254: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
|
||||
0x015F, 0x00FF,
|
||||
];
|
||||
|
||||
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1255: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1256: [u16; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1257: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
|
||||
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
|
||||
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
|
||||
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
|
||||
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
|
||||
0x017E, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1258: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
|
||||
0x20AB, 0x00FF,
|
||||
];
|
||||
|
||||
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
|
||||
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
|
||||
|
||||
/// One byte of a single-byte Windows code page as a char; a code page outside
|
||||
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
|
||||
pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
874 => &CP874,
|
||||
1250 => &CP1250,
|
||||
1251 => &CP1251,
|
||||
1253 => &CP1253,
|
||||
1254 => &CP1254,
|
||||
1255 => &CP1255,
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
return b as char;
|
||||
}
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
|
||||
/// symbol fonts and the double-byte East Asian charsets.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
177 => 1255,
|
||||
178 => 1256,
|
||||
186 => 1257,
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_byte_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
|
||||
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
|
||||
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
|
||||
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
|
||||
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
|
||||
assert_eq!(decode(9999, &[0xE9]), "é");
|
||||
assert_eq!(charset_code_page(204), Some(1251));
|
||||
assert_eq!(charset_code_page(0), None);
|
||||
for cp in SINGLE_BYTE_CODE_PAGES {
|
||||
for b in 0..=255u8 {
|
||||
let _ = windows_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-10
@@ -38,6 +38,7 @@ pub mod resolve;
|
||||
pub mod section;
|
||||
pub mod smart_art;
|
||||
pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod wordart;
|
||||
pub mod wrap;
|
||||
@@ -282,10 +283,11 @@ pub struct TextBody {
|
||||
/// story; its own blocks are unused. See [`Document::text_box_chains`].
|
||||
pub next: Option<u32>,
|
||||
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
|
||||
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1); `None` is Word's defaults.
|
||||
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
|
||||
/// `None` is Word's defaults.
|
||||
pub inset: Option<[u32; 4]>,
|
||||
/// Resize shape to fit text (`a:spAutoFit`): Word grows the box to its text, so none of
|
||||
/// the text is cut.
|
||||
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
|
||||
/// box to its text, so none of the text is cut.
|
||||
pub auto_fit: bool,
|
||||
}
|
||||
|
||||
@@ -478,6 +480,15 @@ pub struct Settings {
|
||||
pub attached_template: Option<String>,
|
||||
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
|
||||
pub link_styles: bool,
|
||||
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
|
||||
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
|
||||
/// the document's creator and last-modified-by. The open document keeps its real values.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_personal_info: bool,
|
||||
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
|
||||
/// comments and tracked changes are saved without `w:date`.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub remove_date_time: bool,
|
||||
}
|
||||
|
||||
/// Word's compatibility mode for documents that don't state one.
|
||||
@@ -555,6 +566,8 @@ impl Default for Settings {
|
||||
bib_style: None,
|
||||
attached_template: None,
|
||||
link_styles: false,
|
||||
remove_personal_info: false,
|
||||
remove_date_time: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,9 +814,19 @@ impl Document {
|
||||
|
||||
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
|
||||
pub fn plain_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, false)
|
||||
}
|
||||
|
||||
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
|
||||
/// Unicode equivalents ([`Paragraph::shown_text`]).
|
||||
pub fn shown_text(&self, s: StoryRef) -> String {
|
||||
self.story_text(s, true)
|
||||
}
|
||||
|
||||
fn story_text(&self, s: StoryRef, shown: bool) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(bl) = self.story(s) {
|
||||
text_of(bl, &mut out, 0);
|
||||
text_of(bl, &mut out, 0, shown);
|
||||
}
|
||||
if out.ends_with('\n') {
|
||||
out.pop();
|
||||
@@ -881,14 +904,22 @@ impl Document {
|
||||
id
|
||||
}
|
||||
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit).
|
||||
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
|
||||
/// including generated numbered-list labels).
|
||||
pub fn word_count(&self) -> usize {
|
||||
count_words(&self.plain_text(StoryRef::Body))
|
||||
self.story_word_count(StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
|
||||
pub fn word_count_including_notes(&self) -> usize {
|
||||
self.counted_stories().iter().map(|s| count_words(&self.plain_text(*s))).sum()
|
||||
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
|
||||
}
|
||||
|
||||
/// Word count of one story, including generated numbered-list labels.
|
||||
pub fn story_word_count(&self, s: StoryRef) -> usize {
|
||||
let Some(bl) = self.story(s) else { return 0 };
|
||||
let mut counters = numbering::Counters::default();
|
||||
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
|
||||
}
|
||||
|
||||
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
|
||||
@@ -1276,14 +1307,14 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
|
||||
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
out.push_str(&p.plain_text());
|
||||
out.push_str(&if shown { p.shown_text() } else { p.plain_text() });
|
||||
out.push('\n');
|
||||
}
|
||||
Block::Table(t) => {
|
||||
@@ -1291,7 +1322,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
|
||||
let mut cells = Vec::new();
|
||||
for c in &row.cells {
|
||||
let mut s = String::new();
|
||||
text_of(&c.blocks, &mut s, depth + 1);
|
||||
text_of(&c.blocks, &mut s, depth + 1, shown);
|
||||
cells.push(s.trim_end_matches('\n').replace('\n', " "));
|
||||
}
|
||||
out.push_str(&cells.join("\t"));
|
||||
@@ -1302,5 +1333,34 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize {
|
||||
if depth > 16 {
|
||||
return 0;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for b in bl {
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
|
||||
if let Some(n) = n
|
||||
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
|
||||
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
|
||||
{
|
||||
total = total.saturating_add(count_words(&label));
|
||||
}
|
||||
total = total.saturating_add(count_words(&p.plain_text()));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
for row in &t.rows {
|
||||
for c in &row.cells {
|
||||
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -208,6 +208,11 @@ impl Numbering {
|
||||
let n = self.num(num)?;
|
||||
self.abstracts.iter().find(|a| a.id == n.abstract_id)
|
||||
}
|
||||
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
|
||||
/// one), without counting it.
|
||||
pub fn shows_label(&self, num: u32, level: u8) -> bool {
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
|
||||
return Some(l);
|
||||
|
||||
+52
-1
@@ -212,6 +212,10 @@ pub struct Float {
|
||||
/// decorative mark). See [`InlineObject::alt_text`].
|
||||
#[serde(skip_serializing_if = "AltText::is_empty")]
|
||||
pub alt: AltText,
|
||||
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
|
||||
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -831,6 +835,30 @@ impl Paragraph {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
|
||||
/// (Symbol, Wingdings…) become their standard Unicode equivalents
|
||||
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
|
||||
/// unchanged.
|
||||
pub fn shown_text(&self) -> String {
|
||||
let mut out = String::with_capacity(self.text.len());
|
||||
let mut k = 0;
|
||||
let mut runs = self.run_ranges().peekable();
|
||||
for (i, c) in self.text.char_indices() {
|
||||
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
|
||||
runs.next();
|
||||
}
|
||||
if c == OBJ {
|
||||
if let Some(o) = self.objects.get(k) {
|
||||
out.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
|
||||
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
|
||||
pub fn final_text(&self) -> String {
|
||||
self.text_without(&self.deleted_ranges())
|
||||
@@ -1040,7 +1068,12 @@ impl Paragraph {
|
||||
if self.runs.is_empty() || a == 0 {
|
||||
// Keep the formatting of what's deleted for the mark when emptying.
|
||||
if b == self.text.len() {
|
||||
self.mark = self.props_of_char(a).clone();
|
||||
// Formatting only: the mark is a tracked change of its own or it is not, whatever
|
||||
// revision the deleted text was part of.
|
||||
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
|
||||
let mut mark = self.props_of_char(a).clone();
|
||||
(mark.ins, mark.del, mark.fmt_change) = revisions;
|
||||
self.mark = mark;
|
||||
}
|
||||
}
|
||||
let k0 = self.objects_before(a);
|
||||
@@ -1412,6 +1445,24 @@ mod tests {
|
||||
assert_eq!(t.mark.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
|
||||
let mut props = bold();
|
||||
props.ins = Some(3);
|
||||
props.del = Some(4);
|
||||
let mut p = Paragraph::with_text("Hello", props);
|
||||
p.mark = bold();
|
||||
p.delete(0, 5).unwrap();
|
||||
assert!(p.is_empty());
|
||||
assert_eq!(p.mark.bold, Some(true));
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, None));
|
||||
// A mark that is itself a tracked change keeps its own revision.
|
||||
let mut p = Paragraph::with_text("Hello", bold());
|
||||
p.mark.del = Some(7);
|
||||
p.delete(0, 5).unwrap();
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words() {
|
||||
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
|
||||
|
||||
+96
-2
@@ -6,9 +6,38 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An sRGB colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub struct Rgb(pub u8, pub u8, pub u8);
|
||||
|
||||
impl<'de> Deserialize<'de> for Rgb {
|
||||
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
|
||||
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
|
||||
struct V;
|
||||
impl<'de> Visitor<'de> for V {
|
||||
type Value = Rgb;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
|
||||
}
|
||||
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
|
||||
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
|
||||
}
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
|
||||
let mut c = [0u8; 3];
|
||||
for (i, x) in c.iter_mut().enumerate() {
|
||||
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
|
||||
}
|
||||
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(4, &self));
|
||||
}
|
||||
Ok(Rgb(c[0], c[1], c[2]))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const BLACK: Rgb = Rgb(0, 0, 0);
|
||||
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
||||
@@ -31,13 +60,42 @@ impl Rgb {
|
||||
}
|
||||
|
||||
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
||||
pub enum TextColor {
|
||||
#[default]
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
|
||||
impl TextColor {
|
||||
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
|
||||
pub fn parse(s: &str) -> Option<TextColor> {
|
||||
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TextColor {
|
||||
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
|
||||
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{Error, Unexpected};
|
||||
#[derive(Deserialize)]
|
||||
enum Written {
|
||||
Auto,
|
||||
Rgb(Rgb),
|
||||
}
|
||||
match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::String(s) => {
|
||||
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
|
||||
}
|
||||
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
|
||||
Written::Auto => TextColor::Auto,
|
||||
Written::Rgb(c) => TextColor::Rgb(c),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Underline {
|
||||
@@ -1125,6 +1183,42 @@ mod tests {
|
||||
assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
|
||||
}
|
||||
|
||||
/// #865: a colour reads as `format.color` takes it (`RRGGBB`, `#RRGGBB`, `auto`), while what
|
||||
/// documents and settings already hold reads and writes exactly as before.
|
||||
#[test]
|
||||
fn text_color_reads_hex_and_keeps_its_serialized_forms() {
|
||||
let read = |j: &str| serde_json::from_str::<TextColor>(j);
|
||||
let ink = TextColor::Rgb(Rgb(0x1B, 0x16, 0x11));
|
||||
assert_eq!(read(r#""1B1611""#).unwrap(), ink);
|
||||
assert_eq!(read(r##""#1b1611""##).unwrap(), ink);
|
||||
assert_eq!(read(r#""auto""#).unwrap(), TextColor::Auto);
|
||||
assert_eq!(read(r#""Automatic""#).unwrap(), TextColor::Auto);
|
||||
for (c, j) in [(TextColor::Auto, r#""Auto""#), (ink, r#"{"Rgb":[27,22,17]}"#)] {
|
||||
assert_eq!(serde_json::to_string(&c).unwrap(), j, "written as before");
|
||||
assert_eq!(read(j).unwrap(), c);
|
||||
}
|
||||
for bad in [r#""1B161""#, r#""zzzzzz""#, r#""""#, r#"{"Rgb":"1B16"}"#, r#"{"Rgb":[1,2]}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
|
||||
assert!(read(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(read(r#"{"Rgb":"1B1611"}"#).unwrap(), ink);
|
||||
let p: CharProps = serde_json::from_value(serde_json::json!({"color": "#1B1611", "bold": true})).unwrap();
|
||||
assert_eq!((p.color, p.bold), (Some(ink), Some(true)));
|
||||
}
|
||||
|
||||
/// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes.
|
||||
#[test]
|
||||
fn rgb_reads_hex_and_keeps_its_serialized_form() {
|
||||
let read = |j: &str| serde_json::from_str::<Rgb>(j);
|
||||
let ink = Rgb(0x1B, 0x16, 0x11);
|
||||
assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before");
|
||||
for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] {
|
||||
assert_eq!(read(j).unwrap(), ink, "{j}");
|
||||
}
|
||||
for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] {
|
||||
assert!(read(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn props_serde_skip_none() {
|
||||
let p = CharProps { bold: Some(true), ..Default::default() };
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to
|
||||
//! standard Unicode characters, so text set in them shows the intended symbols when the font
|
||||
//! isn't installed (they are proprietary and never bundled) and exports as real characters.
|
||||
//!
|
||||
//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own
|
||||
//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code
|
||||
//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text
|
||||
//! never changes: the mapping is for display, layout and plain-text output only.
|
||||
//!
|
||||
//! Sources (Unicode's own published data; no font file was read):
|
||||
//! - Symbol: the Adobe Symbol encoding table published by Unicode,
|
||||
//! <https://www.unicode.org/Public/MAPPINGS/VENDORS/ADOBE/symbol.txt> (codes it maps to the
|
||||
//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU).
|
||||
//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of
|
||||
//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard,
|
||||
//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052,
|
||||
//! L2/11-344), checked against the Unicode Character Database names. The one character not
|
||||
//! encoded (Wingdings 0xFF) has no entry.
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") {
|
||||
Some(&SYMBOL)
|
||||
} else if f.eq_ignore_ascii_case("wingdings") {
|
||||
Some(&WINGDINGS)
|
||||
} else if f.eq_ignore_ascii_case("wingdings 2") {
|
||||
Some(&WINGDINGS_2)
|
||||
} else if f.eq_ignore_ascii_case("wingdings 3") {
|
||||
Some(&WINGDINGS_3)
|
||||
} else if f.eq_ignore_ascii_case("webdings") {
|
||||
Some(&WEBDINGS)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
table(family).is_some()
|
||||
}
|
||||
|
||||
/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the
|
||||
/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD).
|
||||
/// `None` for other fonts, other characters and codes with no Unicode equivalent.
|
||||
pub fn symbol_char(family: &str, c: char) -> Option<char> {
|
||||
let t = table(family)?;
|
||||
let u = u32::from(c);
|
||||
let code = match u {
|
||||
0xF020..=0xF0FF => u - 0xF000,
|
||||
// A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD).
|
||||
0xAD => return None,
|
||||
0x20..=0xFF => u,
|
||||
_ => return None,
|
||||
};
|
||||
let i = usize::try_from(code.checked_sub(0x20)?).ok()?;
|
||||
t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32)
|
||||
}
|
||||
|
||||
/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode
|
||||
/// equivalent; unchanged for other fonts.
|
||||
pub fn map_text(family: &str, text: &str) -> String {
|
||||
if !is_symbol_font(family) {
|
||||
return text.to_string();
|
||||
}
|
||||
text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect()
|
||||
}
|
||||
|
||||
const SYMBOL: [u32; 224] = [
|
||||
0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20
|
||||
0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28
|
||||
0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30
|
||||
0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38
|
||||
0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40
|
||||
0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48
|
||||
0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50
|
||||
0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58
|
||||
0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60
|
||||
0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68
|
||||
0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70
|
||||
0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98
|
||||
0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0
|
||||
0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8
|
||||
0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0
|
||||
0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8
|
||||
0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0
|
||||
0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8
|
||||
0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0
|
||||
0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8
|
||||
0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8
|
||||
0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WEBDINGS: [u32; 224] = [
|
||||
0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20
|
||||
0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28
|
||||
0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30
|
||||
0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38
|
||||
0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40
|
||||
0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48
|
||||
0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50
|
||||
0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58
|
||||
0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60
|
||||
0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68
|
||||
0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70
|
||||
0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78
|
||||
0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80
|
||||
0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88
|
||||
0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90
|
||||
0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98
|
||||
0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0
|
||||
0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8
|
||||
0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0
|
||||
0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8
|
||||
0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0
|
||||
0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8
|
||||
0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0
|
||||
0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8
|
||||
0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0
|
||||
0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8
|
||||
0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0
|
||||
0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS: [u32; 224] = [
|
||||
0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20
|
||||
0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28
|
||||
0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30
|
||||
0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38
|
||||
0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40
|
||||
0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48
|
||||
0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50
|
||||
0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58
|
||||
0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60
|
||||
0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68
|
||||
0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70
|
||||
0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78
|
||||
0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80
|
||||
0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88
|
||||
0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90
|
||||
0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98
|
||||
0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0
|
||||
0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8
|
||||
0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0
|
||||
0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8
|
||||
0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0
|
||||
0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8
|
||||
0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0
|
||||
0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8
|
||||
0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0
|
||||
0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8
|
||||
0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0
|
||||
0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS_2: [u32; 224] = [
|
||||
0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20
|
||||
0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28
|
||||
0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30
|
||||
0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38
|
||||
0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40
|
||||
0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48
|
||||
0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50
|
||||
0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58
|
||||
0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60
|
||||
0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68
|
||||
0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70
|
||||
0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78
|
||||
0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80
|
||||
0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88
|
||||
0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90
|
||||
0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98
|
||||
0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0
|
||||
0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8
|
||||
0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0
|
||||
0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8
|
||||
0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0
|
||||
0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8
|
||||
0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0
|
||||
0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8
|
||||
0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0
|
||||
0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8
|
||||
0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0
|
||||
0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
const WINGDINGS_3: [u32; 224] = [
|
||||
0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20
|
||||
0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28
|
||||
0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30
|
||||
0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38
|
||||
0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40
|
||||
0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48
|
||||
0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50
|
||||
0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58
|
||||
0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60
|
||||
0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68
|
||||
0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70
|
||||
0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78
|
||||
0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80
|
||||
0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88
|
||||
0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90
|
||||
0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98
|
||||
0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0
|
||||
0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8
|
||||
0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0
|
||||
0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8
|
||||
0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0
|
||||
0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8
|
||||
0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0
|
||||
0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8
|
||||
0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0
|
||||
0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8
|
||||
0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0
|
||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_storage_forms_map_alike() {
|
||||
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
|
||||
assert!(is_symbol_font(f));
|
||||
for code in 0x20u32..=0xFF {
|
||||
let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue };
|
||||
if code == 0xAD {
|
||||
assert_eq!(symbol_char(f, plain), None);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_uses_the_adobe_symbol_encoding() {
|
||||
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
|
||||
assert_eq!(symbol_char("symbol", 'a'), Some('α'));
|
||||
assert_eq!(symbol_char("Symbol", 'm'), Some('μ'));
|
||||
assert_eq!(symbol_char("Symbol", 'W'), Some('Ω'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞'));
|
||||
assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√'));
|
||||
// The radical extender is private use in the Adobe table: no equivalent.
|
||||
assert_eq!(symbol_char("Symbol", '\u{F060}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_codes() {
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺'));
|
||||
assert_eq!(symbol_char("Wingdings", 'J'), Some('☺'));
|
||||
assert_eq!(symbol_char("Wingdings", 'L'), Some('☹'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖'));
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}'));
|
||||
// The one character Unicode didn't encode.
|
||||
assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_2_3_and_webdings_codes() {
|
||||
assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}'));
|
||||
assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}'));
|
||||
assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None);
|
||||
assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠'));
|
||||
assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None);
|
||||
assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}'));
|
||||
assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}'));
|
||||
assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_fonts_and_characters_are_left_alone() {
|
||||
assert!(!is_symbol_font("Arial"));
|
||||
assert_eq!(symbol_char("Arial", '\u{F04A}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None);
|
||||
assert_eq!(symbol_char("Wingdings", '\u{1F}'), None);
|
||||
assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}");
|
||||
assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,51 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
#[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 mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
let mut p2 = Paragraph::with_text("Banana", CharProps::default());
|
||||
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
|
||||
let mut p3 = Paragraph::with_text("Cherry", CharProps::default());
|
||||
p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
|
||||
let mut p4 = Paragraph::with_text("Date", CharProps::default());
|
||||
p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 });
|
||||
|
||||
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))];
|
||||
|
||||
// Numbered items contribute 2 words each (label + text) = 4 words.
|
||||
// Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words.
|
||||
assert_eq!(d.word_count(), 6);
|
||||
let plain = d.plain_text(StoryRef::Body);
|
||||
assert_eq!(plain, "Apple\nBanana\nCherry\nDate");
|
||||
}
|
||||
|
||||
#[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 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");
|
||||
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default());
|
||||
p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))];
|
||||
|
||||
// Label "1." and text "Apple" must each be counted as a separate word (2 words),
|
||||
// and label "2." and text "Banana Cherry" as 3 words (total: 5 words),
|
||||
// even though LevelSuffix::Nothing produces no visible separator between label and text.
|
||||
assert_eq!(d.word_count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_and_section_mut() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
|
||||
@@ -16,6 +16,7 @@ mod read;
|
||||
mod smart_art;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
@@ -27,6 +28,7 @@ pub use crypt::{
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use read::read;
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
pub use write::{write, write_as};
|
||||
|
||||
/// Largest `.xml` file read as a Word XML document.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
|
||||
//! charts are drawn from the first supported chart type of the plot area, with their values as
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
|
||||
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…)
|
||||
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, TAU};
|
||||
|
||||
@@ -45,20 +46,21 @@ const TEXT: Rgb = Rgb(0x59, 0x59, 0x59);
|
||||
const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9);
|
||||
const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF);
|
||||
|
||||
/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it.
|
||||
/// Items for a chart part, sized `w` x `h` points: a placeholder when only unsupported chart
|
||||
/// types are in it, empty when there's nothing to draw.
|
||||
pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
let mut out = Vec::new();
|
||||
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return out;
|
||||
}
|
||||
let Some(chart) = Chart::parse(space, theme) else { return out };
|
||||
let Some(chart) = Chart::parse(space, theme) else {
|
||||
placeholder(&mut out, space, theme, w, h);
|
||||
return out;
|
||||
};
|
||||
backdrop(&mut out, space, theme, w, h);
|
||||
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
|
||||
if let Some(t) = &chart.title {
|
||||
let lh = TITLE_SIZE * LINE;
|
||||
text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
|
||||
area.y += lh + PAD;
|
||||
area.h = (area.h - lh - PAD).max(1.0);
|
||||
area = title_block(&mut out, t, area);
|
||||
}
|
||||
if let Some(pos) = chart.legend {
|
||||
// Word lists bar series in the order they are drawn: reversed for clustered horizontal bars
|
||||
@@ -78,6 +80,53 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
|
||||
out
|
||||
}
|
||||
|
||||
/// The title at the top of `area`; returns the room left under it.
|
||||
fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
|
||||
let lh = TITLE_SIZE * LINE;
|
||||
text(out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
|
||||
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
|
||||
}
|
||||
|
||||
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its
|
||||
/// frame with a light border, its title and a line saying which type it is, so it isn't blank
|
||||
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
|
||||
/// to draw.
|
||||
fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
|
||||
let Some(chart) = space.child("c:chart") else { return };
|
||||
let Some(plot) = chart.child("c:plotArea") else { return };
|
||||
if plot.els().any(|e| Kind::of(&e.name).is_some()) {
|
||||
return;
|
||||
}
|
||||
let Some(ty) = plot.els().find(|e| e.name.ends_with("Chart")) else { return };
|
||||
backdrop(out, space, theme, w, h);
|
||||
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill: None, stroke: Some(AXIS), stroke_width: 0.75 });
|
||||
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
|
||||
if let Some(t) = title_of(chart, &read_series(ty, false, theme)) {
|
||||
area = title_block(out, &t, area);
|
||||
}
|
||||
let size = text_size(space);
|
||||
let lh = size * LINE;
|
||||
let msg = format!("{} (not supported yet)", unsupported_name(&ty.name));
|
||||
let line = Rect { x: area.x, y: area.y + ((area.h - lh) / 2.0).max(0.0), w: area.w, h: lh };
|
||||
text(out, line, &clip_text(&msg, area.w, size), size, TextAlign::Center, false);
|
||||
}
|
||||
|
||||
/// What a placeholder calls a chart type element WordCraft doesn't draw.
|
||||
fn unsupported_name(name: &str) -> &'static str {
|
||||
match name {
|
||||
"c:radarChart" => "Radar chart",
|
||||
"c:stockChart" => "Stock chart",
|
||||
"c:bubbleChart" => "Bubble chart",
|
||||
"c:surfaceChart" | "c:surface3DChart" => "Surface chart",
|
||||
"c:ofPieChart" => "Pie of pie chart",
|
||||
"c:bar3DChart" => "3-D bar chart",
|
||||
"c:line3DChart" => "3-D line chart",
|
||||
"c:area3DChart" => "3-D area chart",
|
||||
"c:pie3DChart" => "3-D pie chart",
|
||||
_ => "Chart",
|
||||
}
|
||||
}
|
||||
|
||||
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
|
||||
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
|
||||
struct DataLabel {
|
||||
@@ -1792,10 +1841,27 @@ mod tests {
|
||||
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
|
||||
}
|
||||
|
||||
fn texts(items: &[GraphicItem]) -> Vec<&str> {
|
||||
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_chart_types_draw_nothing() {
|
||||
let plot = r#"<c:radarChart><c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:radarChart>"#;
|
||||
assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty());
|
||||
fn unsupported_chart_types_draw_a_placeholder() {
|
||||
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
|
||||
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
|
||||
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#);
|
||||
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]);
|
||||
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
|
||||
assert!(framed, "a light border marks the chart's frame: {radar:?}");
|
||||
|
||||
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
|
||||
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
|
||||
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
|
||||
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
|
||||
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
|
||||
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
|
||||
}
|
||||
|
||||
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
|
||||
|
||||
@@ -994,6 +994,8 @@ impl Reader<'_> {
|
||||
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
|
||||
}
|
||||
"w:linkStyles" => s.link_styles = on_off(k),
|
||||
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
|
||||
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
|
||||
"w:compat" => {
|
||||
// Only Word's own setting: another `w:uri` names a different application's.
|
||||
let mode = k
|
||||
|
||||
@@ -639,6 +639,11 @@ impl Reader<'_> {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:contentPart" => {
|
||||
if let Some(o) = self.read_ink_content_part(k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:footnoteReference" | "w:endnoteReference" => {
|
||||
let foot = k.name == "w:footnoteReference";
|
||||
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
|
||||
@@ -888,6 +893,7 @@ impl Reader<'_> {
|
||||
let (alt, meta) = nv_alt(c.child("wp:docPr"));
|
||||
let mut float = if anchored { anchor_float(c) } else { Float::default() };
|
||||
float.alt = meta;
|
||||
float.hidden = c.child("wp:docPr").is_some_and(|p| on_off_attr(p, "hidden"));
|
||||
if let Some(e) = c.child("wp:effectExtent") {
|
||||
for (slot, n) in float.effect.iter_mut().zip(["l", "t", "r", "b"]) {
|
||||
// Negative for a rotated object narrower than its frame; settled below.
|
||||
@@ -998,6 +1004,17 @@ impl Reader<'_> {
|
||||
Some(object)
|
||||
}
|
||||
|
||||
fn read_ink_content_part(&mut self, content: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let part = super::part_of(rels, content.attr("r:id")?, INK_REL)?;
|
||||
let pkg = self.pkg;
|
||||
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part) != Some("application/inkml+xml") {
|
||||
return None;
|
||||
}
|
||||
let source = self.keep_embedded(content, rels, &[])?;
|
||||
let graphic = Arc::new(Graphic { kind: GraphicKind::Ink, source: Some(source), ..Default::default() });
|
||||
Some(InlineObject::Graphic { w: 0.0, h: 0.0, alt: String::new(), float: Float::default(), graphic })
|
||||
}
|
||||
|
||||
/// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left
|
||||
/// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word
|
||||
/// stores beside them. Built once per part and size; once the file's graphic budget is spent,
|
||||
@@ -1149,8 +1166,9 @@ impl Reader<'_> {
|
||||
let max = crate::units::MAX_LEN_PT;
|
||||
let fit = |v: f32, lo: f32| wordcraft_geom::finite(v).clamp(lo, max);
|
||||
let (w, h) = (fit(ew * sx, 0.0), fit(eh * sy, 0.0));
|
||||
let (alt, meta) = nv_alt(if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") });
|
||||
let float = Float { alt: meta, ..Float::default() };
|
||||
let nv = if e.name == "pic:pic" { e.find("pic:cNvPr") } else { e.child("wps:cNvPr") };
|
||||
let (alt, meta) = nv_alt(nv);
|
||||
let float = Float { alt: meta, hidden: nv.is_some_and(|p| on_off_attr(p, "hidden")), ..Float::default() };
|
||||
let obj = if e.name == "pic:pic" {
|
||||
self.read_pic(e, rels, w, h, alt.clone(), float)
|
||||
} else {
|
||||
@@ -1294,10 +1312,35 @@ impl Reader<'_> {
|
||||
let stroke = on("stroked").then(|| shape.attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
|
||||
let stroke_width = shape.attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
|
||||
let fill = on("filled").then(|| shape.attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten();
|
||||
if let Some(t) = shape.find("w:txbxContent")
|
||||
if let Some((tb, t)) = vml_textbox(shape, 0)
|
||||
&& sc.story_depth < MAX_STORY_DEPTH
|
||||
{
|
||||
let story = Some(self.read_textbox(sc, t, rels));
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
|
||||
let custom = tb.attr("o:insetmode").is_none_or(|m| !m.eq_ignore_ascii_case("auto"));
|
||||
for (slot, v) in inset.iter_mut().zip(tb.attr("inset").filter(|_| custom).unwrap_or("").split(',')) {
|
||||
if let Some(pt) = measure(v, 12_700.0) {
|
||||
*slot = (pt.max(0.0) * 12_700.0).round().min(wordcraft_doc::MAX_TEXT_BOX_INSET as f32) as u32;
|
||||
}
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
// Resize shape to fit text (DrawingML's `a:spAutoFit`).
|
||||
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text");
|
||||
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
|
||||
// Where the text sits between the box's top and bottom (DrawingML's `anchor`).
|
||||
if let Some(a) = vml_style(style, "v-text-anchor") {
|
||||
part.body.anchor = match a {
|
||||
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center,
|
||||
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom,
|
||||
_ => wordcraft_doc::props::VAlign::Top,
|
||||
};
|
||||
}
|
||||
}
|
||||
let story = Some(id);
|
||||
return Some(InlineObject::Shape {
|
||||
kind: ShapeKind::TextBox,
|
||||
w,
|
||||
@@ -1574,6 +1617,18 @@ fn wrap_polygon(e: &El) -> Option<wordcraft_doc::wrap::WrapPolygon> {
|
||||
}
|
||||
|
||||
/// The value of property `key` in a VML `style` attribute (CSS declarations; the last one wins).
|
||||
/// The first `v:textbox` under `e` holding text, with its `w:txbxContent` (depth-first, bounded
|
||||
/// like `El::find`): in a group, a member's margins go with that member's text.
|
||||
fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
e.els().find_map(|c| match c.child("w:txbxContent") {
|
||||
Some(t) if c.name == "v:textbox" => Some((c, t)),
|
||||
_ => vml_textbox(c, depth + 1),
|
||||
})
|
||||
}
|
||||
|
||||
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
@@ -1586,9 +1641,10 @@ fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
/// `z-index`. Anything else stays inline.
|
||||
fn vml_float(shape: &El, style: &str) -> Float {
|
||||
let get = |k: &str| vml_style(style, k);
|
||||
let hidden = get("visibility").is_some_and(|v| v.eq_ignore_ascii_case("hidden"));
|
||||
if !get("position").is_some_and(|p| p.eq_ignore_ascii_case("absolute")) {
|
||||
// Inline: only the turn (an OLE object turned in WordCraft is saved so, #319).
|
||||
let mut f = Float::default();
|
||||
let mut f = Float { hidden, ..Float::default() };
|
||||
f.set_spin(vml_spin(style));
|
||||
return f;
|
||||
}
|
||||
@@ -1635,6 +1691,7 @@ fn vml_float(shape: &El, style: &str) -> Float {
|
||||
dist: dist("mso-wrap-distance-left").max(dist("mso-wrap-distance-right")),
|
||||
dist_top: dist("mso-wrap-distance-top"),
|
||||
dist_bottom: dist("mso-wrap-distance-bottom"),
|
||||
hidden,
|
||||
..Float::default()
|
||||
};
|
||||
f.set_spin(vml_spin(style));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office
|
||||
//! 2003 XML Reference Schemas). Import only.
|
||||
//! 2003 XML Reference Schemas). Import; the writer is `wordml2003_write`.
|
||||
//!
|
||||
//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`,
|
||||
//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
//! Word 2003 XML Document writer (WordprocessingML 2003, root `w:wordDocument`, from the public
|
||||
//! Office 2003 XML Reference Schemas).
|
||||
//!
|
||||
//! The mirror image of the reader in `wordml2003`: the DOCX writer produces the package, and the
|
||||
//! package is rewritten into the 2003 vocabulary. ECMA-376 kept most of the 2003 element names
|
||||
//! (`w:p`, `w:r`, `w:rPr`, `w:tbl`, `w:sectPr`…), so the body is mostly renamed (`w:szCs` →
|
||||
//! `w:sz-cs`, `w:eastAsia` → `w:fareast`, `w:vMerge` → `w:vmerge`), and what 2003 keeps inline is
|
||||
//! moved back inline: hyperlink targets (`w:hlink`), footnotes and endnotes (inside their
|
||||
//! reference run), headers and footers (inside `w:sectPr`), comments, bookmarks and tracked
|
||||
//! changes (`aml:annotation`), lists (`w:listPr` and a `w:lists` block), pictures (`w:pict` with
|
||||
//! `w:binData` in base64) and the core properties (`o:DocumentProperties`).
|
||||
//!
|
||||
//! What the 2003 format cannot carry is dropped or degraded, never an error: drawings other than
|
||||
//! pictures (shapes, text boxes, groups, charts, SmartArt, ink), picture crop, rotation and
|
||||
//! floating position (pictures become inline), content controls (their content stays), math,
|
||||
//! revisions of formatting, theme colours and fonts, custom XML, macros, embedded objects and
|
||||
//! `docDefaults` other than fonts (folded into the Normal style). Elements outside the 2003
|
||||
//! vocabulary are not written, so Word has nothing to reject. Bounded: nesting is capped by the
|
||||
//! parser's depth limit, pictures and stories are capped in number.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use base64::Engine as _;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::flat::W2003_NS;
|
||||
use crate::package::{Package, Rels, rt};
|
||||
use crate::xml::{El, MAX_DEPTH, Node};
|
||||
use crate::{DocxError, Flavor};
|
||||
|
||||
const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint";
|
||||
const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core";
|
||||
const VML_NS: &str = "urn:schemas-microsoft-com:vml";
|
||||
const OFFICE_NS: &str = "urn:schemas-microsoft-com:office:office";
|
||||
const W10_NS: &str = "urn:schemas-microsoft-com:office:word";
|
||||
|
||||
/// Most pictures written (the reader takes as many).
|
||||
const MAX_MEDIA: usize = 4096;
|
||||
/// Most header, footer, footnote, endnote and comment stories written.
|
||||
const MAX_STORIES: usize = 50_000;
|
||||
/// A picture's longest side, points (a corrupt extent must not give a huge frame).
|
||||
const MAX_PICT_PT: f64 = 31_680.0;
|
||||
|
||||
/// Write `doc` as a Word 2003 XML Document.
|
||||
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||||
let parts: BTreeMap<String, Vec<u8>> = crate::write::write_parts(doc, Flavor::Document)?.into_iter().collect();
|
||||
let pkg = Package::from_parts(parts);
|
||||
let doc_part = pkg
|
||||
.rels("_rels/.rels")
|
||||
.by_type(rt::OFFICE_DOC)
|
||||
.map(|r| r.target.clone())
|
||||
.filter(|t| pkg.get(t).is_some())
|
||||
.unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let mut c = Conv::new(&pkg, &doc_part);
|
||||
|
||||
let body = match pkg.xml(&doc_part)? {
|
||||
Some(root) => {
|
||||
let rels = c.doc_rels.clone();
|
||||
match root.child("w:body") {
|
||||
Some(b) => c.kids(b, &rels, "w:body", 1),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let mut kids: Vec<Node> = vec![Node::El(core_props(doc))];
|
||||
if let Some(f) = c.default_fonts() {
|
||||
kids.push(Node::El(f));
|
||||
}
|
||||
if let Some(l) = c.lists() {
|
||||
kids.push(Node::El(l));
|
||||
}
|
||||
kids.push(Node::El(c.styles()));
|
||||
kids.push(Node::El(plain(
|
||||
"w:docPr",
|
||||
vec![Node::El(attr_el("w:view", &[("w:val", "print")])), Node::El(attr_el("w:zoom", &[("w:percent", "100")]))],
|
||||
)));
|
||||
kids.push(Node::El(plain("w:body", vec![Node::El(plain("wx:sect", body))])));
|
||||
|
||||
let attrs = [
|
||||
("xmlns:w", W2003_NS),
|
||||
("xmlns:wx", AUX_NS),
|
||||
("xmlns:o", OFFICE_NS),
|
||||
("xmlns:v", VML_NS),
|
||||
("xmlns:w10", W10_NS),
|
||||
("xmlns:aml", AML_NS),
|
||||
("w:macrosPresent", "no"),
|
||||
("w:embeddedObjPresent", "no"),
|
||||
("w:ocxPresent", "no"),
|
||||
("xml:space", "preserve"),
|
||||
];
|
||||
let root = El { name: "w:wordDocument".into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids };
|
||||
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
|
||||
s.push_str(&root.to_xml());
|
||||
Ok(s.into_bytes())
|
||||
}
|
||||
|
||||
fn plain(name: &str, kids: Vec<Node>) -> El {
|
||||
El { name: name.into(), attrs: Vec::new(), kids }
|
||||
}
|
||||
|
||||
fn attr_el(name: &str, attrs: &[(&str, &str)]) -> El {
|
||||
El { name: name.into(), attrs: attrs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(), kids: Vec::new() }
|
||||
}
|
||||
|
||||
fn text_el(name: &str, text: &str) -> El {
|
||||
El { name: name.into(), attrs: Vec::new(), kids: vec![Node::Text(text.to_string())] }
|
||||
}
|
||||
|
||||
/// `o:DocumentProperties` from the core properties.
|
||||
fn core_props(doc: &Document) -> El {
|
||||
let c = &doc.core;
|
||||
let mut kids = Vec::new();
|
||||
let revision = c.revision.to_string();
|
||||
for (name, v) in [
|
||||
("o:Title", c.title.as_str()),
|
||||
("o:Subject", c.subject.as_str()),
|
||||
("o:Author", c.creator.as_str()),
|
||||
("o:Keywords", c.keywords.as_str()),
|
||||
("o:Description", c.description.as_str()),
|
||||
("o:LastAuthor", c.last_modified_by.as_str()),
|
||||
("o:Revision", if c.revision > 0 { revision.as_str() } else { "" }),
|
||||
("o:Created", c.created.as_str()),
|
||||
("o:LastSaved", c.modified.as_str()),
|
||||
("o:Category", c.category.as_str()),
|
||||
] {
|
||||
if !v.trim().is_empty() {
|
||||
kids.push(Node::El(text_el(name, v.trim())));
|
||||
}
|
||||
}
|
||||
plain("o:DocumentProperties", kids)
|
||||
}
|
||||
|
||||
struct Conv<'a> {
|
||||
pkg: &'a Package,
|
||||
doc_rels: Rels,
|
||||
footnotes: HashMap<String, El>,
|
||||
endnotes: HashMap<String, El>,
|
||||
comments: HashMap<String, El>,
|
||||
foot_rels: Rels,
|
||||
end_rels: Rels,
|
||||
comment_rels: Rels,
|
||||
styles_part: Option<El>,
|
||||
numbering_part: Option<El>,
|
||||
/// Picture part path → `wordml://` name, for the pictures already given a `w:binData`.
|
||||
media: HashMap<String, String>,
|
||||
/// `w:binData` elements waiting for the `w:pict` being written.
|
||||
bins: Vec<El>,
|
||||
/// Annotation ids: (kind, original id) → the id written (one number space, as in 2003).
|
||||
ann_ids: HashMap<(char, String), i64>,
|
||||
next_ann: i64,
|
||||
next_shape: i64,
|
||||
stories: usize,
|
||||
in_note: bool,
|
||||
}
|
||||
|
||||
impl<'a> Conv<'a> {
|
||||
fn new(pkg: &'a Package, doc_part: &str) -> Conv<'a> {
|
||||
let doc_rels = pkg.rels(doc_part);
|
||||
let part_of = |kind: &str| doc_rels.by_type(kind).map(|r| r.target.clone());
|
||||
let notes = |kind: &str, local: &str| -> (HashMap<String, El>, Rels) {
|
||||
let Some(path) = part_of(kind) else { return (HashMap::new(), Rels::default()) };
|
||||
let map = pkg
|
||||
.xml(&path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|root| {
|
||||
root.els()
|
||||
.filter(|e| e.name == local)
|
||||
.filter(|e| e.attr("w:type").is_none())
|
||||
.filter_map(|e| e.attr("w:id").map(|id| (id.to_string(), e.clone())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(map, pkg.rels(&path))
|
||||
};
|
||||
let (footnotes, foot_rels) = notes(rt::FOOTNOTES, "w:footnote");
|
||||
let (endnotes, end_rels) = notes(rt::ENDNOTES, "w:endnote");
|
||||
let (comments, comment_rels) = notes(rt::COMMENTS, "w:comment");
|
||||
let load = |kind: &str| part_of(kind).and_then(|p| pkg.xml(&p).ok().flatten());
|
||||
Conv {
|
||||
pkg,
|
||||
styles_part: load(rt::STYLES),
|
||||
numbering_part: load(rt::NUMBERING),
|
||||
doc_rels,
|
||||
footnotes,
|
||||
endnotes,
|
||||
comments,
|
||||
foot_rels,
|
||||
end_rels,
|
||||
comment_rels,
|
||||
media: HashMap::new(),
|
||||
bins: Vec::new(),
|
||||
ann_ids: HashMap::new(),
|
||||
next_ann: 0,
|
||||
next_shape: 1025,
|
||||
stories: 0,
|
||||
in_note: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The annotation id for `(kind, id)`: the same each time it is asked.
|
||||
fn ann_id(&mut self, kind: char, id: &str) -> i64 {
|
||||
if let Some(n) = self.ann_ids.get(&(kind, id.to_string())) {
|
||||
return *n;
|
||||
}
|
||||
let n = self.next_ann;
|
||||
self.next_ann = self.next_ann.saturating_add(1);
|
||||
self.ann_ids.insert((kind, id.to_string()), n);
|
||||
n
|
||||
}
|
||||
|
||||
fn kids(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
|
||||
let mut out = Vec::with_capacity(e.kids.len());
|
||||
for n in &e.kids {
|
||||
match n {
|
||||
Node::Text(t) => out.push(Node::Text(t.clone())),
|
||||
Node::El(k) => out.extend(self.node(k, rels, parent, depth + 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `e` rewritten for 2003: nothing (dropped), its content (unwrapped) or one element.
|
||||
fn node(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Vec::new();
|
||||
}
|
||||
let name = e.name.as_str();
|
||||
match name {
|
||||
// Content controls: the content stays, the control goes.
|
||||
"w:sdt" => return e.child("w:sdtContent").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
|
||||
"w:drawing" => return self.picture(e, rels),
|
||||
// A VML fallback is kept for what the modern drawing could not be.
|
||||
"mc:AlternateContent" => return e.child("mc:Fallback").map(|c| self.kids(c, rels, parent, depth)).unwrap_or_default(),
|
||||
"w:object" | "w:txbxContent" => return Vec::new(),
|
||||
"w:footnoteReference" | "w:endnoteReference" => return self.note(e, depth),
|
||||
"w:commentReference" => return self.comment(e, depth),
|
||||
"w:commentRangeStart" | "w:commentRangeEnd" | "w:bookmarkStart" | "w:bookmarkEnd" => return self.range_marker(e),
|
||||
"w:ins" | "w:del" | "w:moveFrom" | "w:moveTo" => {
|
||||
// Paragraph-mark and row revisions sit in property blocks: no 2003 form.
|
||||
if matches!(parent, "w:rPr" | "w:trPr") {
|
||||
return Vec::new();
|
||||
}
|
||||
return self.revision(e, rels, parent, depth);
|
||||
}
|
||||
"w:hyperlink" => return vec![Node::El(self.hyperlink(e, rels, depth))],
|
||||
"w:numPr" if parent == "w:pPr" => return vec![Node::El(list_pr(e))],
|
||||
"w:numFmt" if parent == "w:lvl" => return vec![Node::El(nfc(e))],
|
||||
"w:sectPr" => return vec![Node::El(self.section(e, rels, depth))],
|
||||
"w:pict" => return vec![Node::El(self.vml_pict(e, rels, depth))],
|
||||
"w:tblLook" => {
|
||||
return match e.attr("w:val") {
|
||||
Some(v) => vec![Node::El(attr_el("w:tblLook", &[("w:val", v)]))],
|
||||
None => Vec::new(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !allowed(name) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut el = El { name: rename(name), attrs: attrs(e), kids: Vec::new() };
|
||||
if name == "v:imagedata" {
|
||||
let id = e.attr("r:id").or_else(|| e.attr("r:pict")).map(str::to_string);
|
||||
if let Some(src) = id.and_then(|id| rels.by_id(&id).map(|r| r.target.clone())).and_then(|t| self.bin_for(&t)) {
|
||||
el.attrs.retain(|(k, _)| k != "src");
|
||||
el.attrs.push(("src".into(), src));
|
||||
} else {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
el.kids = self.kids(e, rels, name, depth);
|
||||
vec![Node::El(el)]
|
||||
}
|
||||
|
||||
/// A `wordml://` name for the picture at package path `target`, queueing its `w:binData` the
|
||||
/// first time. `None` for a format 2003 XML pictures don't hold, or past the cap.
|
||||
fn bin_for(&mut self, target: &str) -> Option<String> {
|
||||
if let Some(n) = self.media.get(target) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let ext = target.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?;
|
||||
if !matches!(ext.as_str(), "png" | "jpeg" | "jpg" | "gif" | "bmp" | "emf" | "wmf" | "tif" | "tiff") || self.media.len() >= MAX_MEDIA {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.pkg.get(target)?;
|
||||
let name = format!("wordml://0300{:04}.{ext}", self.media.len() + 1);
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let bin = El {
|
||||
name: "w:binData".into(),
|
||||
attrs: vec![("w:name".into(), name.clone()), ("xml:space".into(), "preserve".into())],
|
||||
kids: vec![Node::Text(b64)],
|
||||
};
|
||||
self.bins.push(bin);
|
||||
self.media.insert(target.to_string(), name.clone());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
|
||||
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
|
||||
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
|
||||
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
|
||||
let Some(src) = self.bin_for(&target) else { return Vec::new() };
|
||||
let pt = |k: &str| {
|
||||
let emu = drawing.find("wp:extent").and_then(|x| x.attr(k)).and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(0.0);
|
||||
(if emu.is_finite() { emu / 12700.0 } else { 0.0 }).clamp(0.0, MAX_PICT_PT)
|
||||
};
|
||||
let style = format!("width:{}pt;height:{}pt", trim_num(pt("cx")), trim_num(pt("cy")));
|
||||
let alt = drawing.find("wp:docPr").and_then(|d| d.attr("descr")).unwrap_or("").to_string();
|
||||
let shape_id = format!("_x0000_i{}", self.next_shape);
|
||||
self.next_shape = self.next_shape.saturating_add(1);
|
||||
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
|
||||
vec![Node::El(plain("w:pict", kids))]
|
||||
}
|
||||
|
||||
/// The `v:shape` holding a picture's `v:imagedata`.
|
||||
fn shape_frame(&self, id: &str, style: &str, src: &str, alt: &str) -> El {
|
||||
let mut shape = attr_el(
|
||||
"v:shape",
|
||||
&[
|
||||
("id", id),
|
||||
("style", style),
|
||||
("coordsize", "21600,21600"),
|
||||
("o:spt", "75"),
|
||||
("path", "m0,0l0,21600,21600,21600,21600,0xe"),
|
||||
("filled", "f"),
|
||||
("stroked", "f"),
|
||||
],
|
||||
);
|
||||
if !alt.is_empty() {
|
||||
shape.attrs.push(("alt".into(), alt.to_string()));
|
||||
}
|
||||
let mut data = attr_el("v:imagedata", &[("src", src)]);
|
||||
data.attrs.push(("o:title".into(), alt.to_string()));
|
||||
shape.kids.push(Node::El(data));
|
||||
shape
|
||||
}
|
||||
|
||||
/// A legacy `w:pict` (VML) kept as it is, its pictures given `w:binData`.
|
||||
fn vml_pict(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let kids = self.kids(e, rels, "w:pict", depth);
|
||||
let mut all: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
|
||||
all.extend(kids);
|
||||
plain("w:pict", all)
|
||||
}
|
||||
|
||||
fn hyperlink(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let mut a = Vec::new();
|
||||
if let Some(r) = e.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| r.external) {
|
||||
a.push(("w:dest".to_string(), r.target.clone()));
|
||||
}
|
||||
for (from, to) in [("w:anchor", "w:bookmark"), ("w:tooltip", "w:screenTip"), ("w:tgtFrame", "w:target")] {
|
||||
if let Some(v) = e.attr(from) {
|
||||
a.push((to.to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
El { name: "w:hlink".into(), attrs: a, kids: self.kids(e, rels, "w:hlink", depth) }
|
||||
}
|
||||
|
||||
/// A footnote or endnote reference as the note itself, inline in its run.
|
||||
fn note(&mut self, e: &El, depth: usize) -> Vec<Node> {
|
||||
let foot = e.name == "w:footnoteReference";
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
if self.in_note || self.stories >= MAX_STORIES {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(note) = (if foot { self.footnotes.get(id) } else { self.endnotes.get(id) }).cloned() else { return Vec::new() };
|
||||
self.stories += 1;
|
||||
let rels = if foot { self.foot_rels.clone() } else { self.end_rels.clone() };
|
||||
self.in_note = true;
|
||||
let name = if foot { "w:footnote" } else { "w:endnote" };
|
||||
let kids = self.kids(¬e, &rels, name, depth);
|
||||
self.in_note = false;
|
||||
vec![Node::El(plain(name, kids))]
|
||||
}
|
||||
|
||||
fn annotation(&self, kind: &str, id: i64, mut extra: Vec<(String, String)>, content: Option<Vec<Node>>) -> El {
|
||||
let mut a = vec![("aml:id".to_string(), id.to_string())];
|
||||
a.append(&mut extra);
|
||||
a.push(("w:type".into(), kind.into()));
|
||||
El { name: "aml:annotation".into(), attrs: a, kids: content.map(|c| vec![Node::El(plain("aml:content", c))]).unwrap_or_default() }
|
||||
}
|
||||
|
||||
fn comment(&mut self, e: &El, depth: usize) -> Vec<Node> {
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
if self.in_note || self.stories >= MAX_STORIES {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(c) = self.comments.get(id).cloned() else { return Vec::new() };
|
||||
self.stories += 1;
|
||||
let rels = self.comment_rels.clone();
|
||||
let n = self.ann_id('c', id);
|
||||
self.in_note = true;
|
||||
let content = self.kids(&c, &rels, "w:comment", depth);
|
||||
self.in_note = false;
|
||||
let mut extra = Vec::new();
|
||||
if let Some(v) = c.attr("w:author") {
|
||||
extra.push(("aml:author".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = c.attr("w:date") {
|
||||
extra.push(("aml:createdate".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = c.attr("w:initials") {
|
||||
extra.push(("w:initials".to_string(), v.to_string()));
|
||||
}
|
||||
vec![Node::El(self.annotation("Word.Comment", n, extra, Some(content)))]
|
||||
}
|
||||
|
||||
fn range_marker(&mut self, e: &El) -> Vec<Node> {
|
||||
let Some(id) = e.attr("w:id") else { return Vec::new() };
|
||||
let (kind, ty) = match e.name.as_str() {
|
||||
"w:commentRangeStart" => ('c', "Word.Comment.Start"),
|
||||
"w:commentRangeEnd" => ('c', "Word.Comment.End"),
|
||||
"w:bookmarkStart" => ('b', "Word.Bookmark.Start"),
|
||||
_ => ('b', "Word.Bookmark.End"),
|
||||
};
|
||||
let n = self.ann_id(kind, id);
|
||||
let extra = if e.name == "w:bookmarkStart" { vec![("w:name".to_string(), e.attr("w:name").unwrap_or("").to_string())] } else { Vec::new() };
|
||||
vec![Node::El(self.annotation(ty, n, extra, None))]
|
||||
}
|
||||
|
||||
/// Tracked insertions and deletions (moves become a deletion and an insertion).
|
||||
fn revision(&mut self, e: &El, rels: &Rels, parent: &str, depth: usize) -> Vec<Node> {
|
||||
let ty = if matches!(e.name.as_str(), "w:ins" | "w:moveTo") { "Word.Insertion" } else { "Word.Deletion" };
|
||||
let n = self.ann_id('r', e.attr("w:id").unwrap_or(""));
|
||||
let mut extra = Vec::new();
|
||||
if let Some(v) = e.attr("w:author") {
|
||||
extra.push(("aml:author".to_string(), v.to_string()));
|
||||
}
|
||||
if let Some(v) = e.attr("w:date") {
|
||||
extra.push(("aml:createdate".to_string(), v.to_string()));
|
||||
}
|
||||
let content = self.kids(e, rels, parent, depth);
|
||||
vec![Node::El(self.annotation(ty, n, extra, Some(content)))]
|
||||
}
|
||||
|
||||
/// `w:sectPr` with its headers and footers inline (`w:hdr`, `w:ftr`).
|
||||
fn section(&mut self, e: &El, rels: &Rels, depth: usize) -> El {
|
||||
let mut stories = Vec::new();
|
||||
let mut rest = Vec::new();
|
||||
for k in e.els() {
|
||||
if k.name == "w:headerReference" || k.name == "w:footerReference" {
|
||||
let footer = k.name == "w:footerReference";
|
||||
if self.stories >= MAX_STORIES {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = k.attr("r:id").and_then(|id| rels.by_id(id)).filter(|r| !r.external).map(|r| r.target.clone()) else { continue };
|
||||
let Some(root) = self.pkg.xml(&path).ok().flatten() else { continue };
|
||||
self.stories += 1;
|
||||
let story_rels = self.pkg.rels(&path);
|
||||
let ty = match k.attr("w:type") {
|
||||
Some("even") => "even",
|
||||
Some("first") => "first",
|
||||
_ => "odd",
|
||||
};
|
||||
let name = if footer { "w:ftr" } else { "w:hdr" };
|
||||
let was = std::mem::replace(&mut self.in_note, true);
|
||||
let kids = self.kids(&root, &story_rels, name, depth + 1);
|
||||
self.in_note = was;
|
||||
let mut story = plain(name, kids);
|
||||
story.attrs.push(("w:type".into(), ty.into()));
|
||||
stories.push(Node::El(story));
|
||||
} else {
|
||||
rest.extend(self.node(k, rels, "w:sectPr", depth + 1));
|
||||
}
|
||||
}
|
||||
stories.extend(rest);
|
||||
El { name: "w:sectPr".into(), attrs: Vec::new(), kids: stories }
|
||||
}
|
||||
|
||||
/// `w:fonts/w:defaultFonts` from the document defaults.
|
||||
fn default_fonts(&self) -> Option<El> {
|
||||
let f = self.styles_part.as_ref()?.child("w:docDefaults")?.child("w:rPrDefault")?.child("w:rPr")?.child("w:rFonts")?;
|
||||
let a = attrs(f);
|
||||
(!a.is_empty()).then(|| plain("w:fonts", vec![Node::El(El { name: "w:defaultFonts".into(), attrs: a, kids: Vec::new() })]))
|
||||
}
|
||||
|
||||
/// `w:styles`: the styles, with the document defaults (other than fonts) folded into the
|
||||
/// default paragraph style, as 2003 has no `docDefaults`.
|
||||
fn styles(&mut self) -> El {
|
||||
let Some(root) = self.styles_part.clone() else { return plain("w:styles", Vec::new()) };
|
||||
let defaults = root.child("w:docDefaults");
|
||||
let default_of = |part: &str, block: &str| -> Vec<El> {
|
||||
defaults
|
||||
.and_then(|d| d.child(part))
|
||||
.and_then(|p| p.child(block))
|
||||
.map(|b| b.els().filter(|e| e.name != "w:rFonts").cloned().collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (def_r, def_p) = (default_of("w:rPrDefault", "w:rPr"), default_of("w:pPrDefault", "w:pPr"));
|
||||
let rels = self.doc_rels.clone();
|
||||
let mut kids = Vec::new();
|
||||
for st in root.children("w:style") {
|
||||
let mut st = st.clone();
|
||||
if st.attr("w:type") == Some("paragraph") && matches!(st.attr("w:default"), Some("1" | "true" | "on")) {
|
||||
fold(&mut st, "w:pPr", &def_p);
|
||||
fold(&mut st, "w:rPr", &def_r);
|
||||
}
|
||||
kids.extend(self.node(&st, &rels, "w:styles", 2));
|
||||
}
|
||||
plain("w:styles", kids)
|
||||
}
|
||||
|
||||
/// `w:lists`: `w:abstractNum` → `w:listDef`, `w:num` → `w:list`.
|
||||
fn lists(&mut self) -> Option<El> {
|
||||
let root = self.numbering_part.clone()?;
|
||||
let rels = self.doc_rels.clone();
|
||||
let mut kids = Vec::new();
|
||||
for abs in root.children("w:abstractNum") {
|
||||
let Some(id) = abs.attr("w:abstractNumId") else { continue };
|
||||
let mut def = El { name: "w:listDef".into(), attrs: vec![("w:listDefId".into(), id.to_string())], kids: Vec::new() };
|
||||
for k in abs.els() {
|
||||
match k.name.as_str() {
|
||||
"w:nsid" => def.kids.push(Node::El(attr_el("w:lsid", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
|
||||
"w:multiLevelType" => {
|
||||
let v = match k.attr("w:val").unwrap_or("") {
|
||||
"singleLevel" => "SingleLevel",
|
||||
"hybridMultilevel" => "HybridMultilevel",
|
||||
_ => "Multilevel",
|
||||
};
|
||||
def.kids.push(Node::El(attr_el("w:plt", &[("w:val", v)])));
|
||||
}
|
||||
"w:numStyleLink" => def.kids.push(Node::El(attr_el("w:listStyleLink", &[("w:val", k.attr("w:val").unwrap_or(""))]))),
|
||||
"w:lvl" => def.kids.extend(self.node(k, &rels, "w:abstractNum", 3)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
kids.push(Node::El(def));
|
||||
}
|
||||
for num in root.children("w:num") {
|
||||
let (Some(id), Some(abs)) = (num.attr("w:numId"), num.child_val("w:abstractNumId")) else { continue };
|
||||
let mut list = El { name: "w:list".into(), attrs: vec![("w:ilfo".into(), id.to_string())], kids: Vec::new() };
|
||||
list.kids.push(Node::El(attr_el("w:ilst", &[("w:val", abs)])));
|
||||
for o in num.children("w:lvlOverride") {
|
||||
list.kids.extend(self.node(o, &rels, "w:num", 3));
|
||||
}
|
||||
kids.push(Node::El(list));
|
||||
}
|
||||
(!kids.is_empty()).then(|| plain("w:lists", kids))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the properties of `defaults` that block `name` of `style` doesn't set (at its start).
|
||||
fn fold(style: &mut El, name: &str, defaults: &[El]) {
|
||||
if defaults.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name == name));
|
||||
let idx = match pos {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
// Schema order within a style: name, basedOn, next, link, … then pPr, rPr.
|
||||
let at = if name == "w:pPr" {
|
||||
style
|
||||
.kids
|
||||
.iter()
|
||||
.position(|n| matches!(n, Node::El(e) if e.name == "w:rPr" || e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
|
||||
} else {
|
||||
style.kids.iter().position(|n| matches!(n, Node::El(e) if e.name.starts_with("w:tbl") || e.name.starts_with("w:t")))
|
||||
}
|
||||
.unwrap_or(style.kids.len());
|
||||
style.kids.insert(at, Node::El(plain(name, Vec::new())));
|
||||
at
|
||||
}
|
||||
};
|
||||
if let Some(Node::El(block)) = style.kids.get_mut(idx) {
|
||||
let have: Vec<String> = block.els().map(|e| e.name.clone()).collect();
|
||||
let mut add: Vec<Node> = defaults.iter().filter(|d| !have.contains(&d.name)).cloned().map(Node::El).collect();
|
||||
add.append(&mut block.kids);
|
||||
block.kids = add;
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:numPr` (`w:ilvl`, `w:numId`) → `w:listPr` (`w:ilvl`, `w:ilfo`).
|
||||
fn list_pr(e: &El) -> El {
|
||||
let mut kids = Vec::new();
|
||||
if let Some(v) = e.child_val("w:ilvl") {
|
||||
kids.push(Node::El(attr_el("w:ilvl", &[("w:val", v)])));
|
||||
}
|
||||
if let Some(v) = e.child_val("w:numId") {
|
||||
kids.push(Node::El(attr_el("w:ilfo", &[("w:val", v)])));
|
||||
}
|
||||
plain("w:listPr", kids)
|
||||
}
|
||||
|
||||
/// `w:numFmt` → `w:nfc` (a number format code).
|
||||
fn nfc(e: &El) -> El {
|
||||
let code = match e.attr("w:val").unwrap_or("decimal") {
|
||||
"upperRoman" => "1",
|
||||
"lowerRoman" => "2",
|
||||
"upperLetter" => "3",
|
||||
"lowerLetter" => "4",
|
||||
"ordinal" => "5",
|
||||
"cardinalText" => "6",
|
||||
"ordinalText" => "7",
|
||||
"chicago" => "9",
|
||||
"decimalFullWidth" => "14",
|
||||
"decimalHalfWidth" => "15",
|
||||
"decimalEnclosedCircle" => "18",
|
||||
"decimalZero" => "22",
|
||||
"bullet" => "23",
|
||||
"ganada" => "24",
|
||||
"chosung" => "25",
|
||||
"decimalEnclosedFullstop" => "26",
|
||||
"decimalEnclosedParen" => "27",
|
||||
"none" => "255",
|
||||
_ => "0",
|
||||
};
|
||||
attr_el("w:nfc", &[("w:val", code)])
|
||||
}
|
||||
|
||||
/// A number without a pointless fraction (`36`, `10.5`).
|
||||
fn trim_num(v: f64) -> String {
|
||||
let s = format!("{v:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// Is this element written? WordprocessingML elements of the 2003 vocabulary, and the VML a
|
||||
/// picture needs; everything else (DrawingML, `w14`, math, markup compatibility…) is not.
|
||||
fn allowed(name: &str) -> bool {
|
||||
match name.split_once(':') {
|
||||
Some(("w", local)) => allowed_w(local),
|
||||
Some(("v" | "o" | "w10", _)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_w(local: &str) -> bool {
|
||||
matches!(
|
||||
local,
|
||||
"p" | "pPr"
|
||||
| "r"
|
||||
| "rPr"
|
||||
| "t"
|
||||
| "tab"
|
||||
| "br"
|
||||
| "cr"
|
||||
| "noBreakHyphen"
|
||||
| "softHyphen"
|
||||
| "sym"
|
||||
| "pgNum"
|
||||
| "delText"
|
||||
| "instrText"
|
||||
| "fldChar"
|
||||
| "fldSimple"
|
||||
| "footnoteRef"
|
||||
| "endnoteRef"
|
||||
| "annotationRef"
|
||||
| "separator"
|
||||
| "continuationSeparator"
|
||||
| "tbl"
|
||||
| "tblPr"
|
||||
| "tblGrid"
|
||||
| "gridCol"
|
||||
| "tr"
|
||||
| "trPr"
|
||||
| "tc"
|
||||
| "tcPr"
|
||||
| "sectPr"
|
||||
| "hdr"
|
||||
| "ftr"
|
||||
| "pStyle"
|
||||
| "keepNext"
|
||||
| "keepLines"
|
||||
| "pageBreakBefore"
|
||||
| "framePr"
|
||||
| "widowControl"
|
||||
| "suppressLineNumbers"
|
||||
| "pBdr"
|
||||
| "shd"
|
||||
| "tabs"
|
||||
| "suppressAutoHyphens"
|
||||
| "kinsoku"
|
||||
| "wordWrap"
|
||||
| "overflowPunct"
|
||||
| "topLinePunct"
|
||||
| "autoSpaceDE"
|
||||
| "autoSpaceDN"
|
||||
| "bidi"
|
||||
| "adjustRightInd"
|
||||
| "snapToGrid"
|
||||
| "spacing"
|
||||
| "ind"
|
||||
| "contextualSpacing"
|
||||
| "mirrorIndents"
|
||||
| "textAlignment"
|
||||
| "textDirection"
|
||||
| "textboxTightWrap"
|
||||
| "outlineLvl"
|
||||
| "jc"
|
||||
| "divId"
|
||||
| "top"
|
||||
| "left"
|
||||
| "bottom"
|
||||
| "right"
|
||||
| "between"
|
||||
| "bar"
|
||||
| "insideH"
|
||||
| "insideV"
|
||||
| "tl2br"
|
||||
| "tr2bl"
|
||||
| "rStyle"
|
||||
| "rFonts"
|
||||
| "b"
|
||||
| "bCs"
|
||||
| "i"
|
||||
| "iCs"
|
||||
| "caps"
|
||||
| "smallCaps"
|
||||
| "strike"
|
||||
| "dstrike"
|
||||
| "outline"
|
||||
| "shadow"
|
||||
| "emboss"
|
||||
| "imprint"
|
||||
| "noProof"
|
||||
| "vanish"
|
||||
| "webHidden"
|
||||
| "color"
|
||||
| "w"
|
||||
| "kern"
|
||||
| "position"
|
||||
| "sz"
|
||||
| "szCs"
|
||||
| "highlight"
|
||||
| "u"
|
||||
| "effect"
|
||||
| "bdr"
|
||||
| "fitText"
|
||||
| "vertAlign"
|
||||
| "rtl"
|
||||
| "cs"
|
||||
| "em"
|
||||
| "lang"
|
||||
| "eastAsianLayout"
|
||||
| "specVanish"
|
||||
| "tblStyle"
|
||||
| "tblpPr"
|
||||
| "tblOverlap"
|
||||
| "bidiVisual"
|
||||
| "tblStyleRowBandSize"
|
||||
| "tblStyleColBandSize"
|
||||
| "tblW"
|
||||
| "tblCellSpacing"
|
||||
| "tblInd"
|
||||
| "tblBorders"
|
||||
| "tblLayout"
|
||||
| "tblCellMar"
|
||||
| "cantSplit"
|
||||
| "trHeight"
|
||||
| "tblHeader"
|
||||
| "gridBefore"
|
||||
| "gridAfter"
|
||||
| "wBefore"
|
||||
| "wAfter"
|
||||
| "hidden"
|
||||
| "tcW"
|
||||
| "gridSpan"
|
||||
| "hMerge"
|
||||
| "vMerge"
|
||||
| "tcBorders"
|
||||
| "noWrap"
|
||||
| "tcMar"
|
||||
| "tcFitText"
|
||||
| "vAlign"
|
||||
| "pgSz"
|
||||
| "pgMar"
|
||||
| "paperSrc"
|
||||
| "pgBorders"
|
||||
| "lnNumType"
|
||||
| "pgNumType"
|
||||
| "cols"
|
||||
| "col"
|
||||
| "formProt"
|
||||
| "noEndnote"
|
||||
| "titlePg"
|
||||
| "rtlGutter"
|
||||
| "docGrid"
|
||||
| "type"
|
||||
| "style"
|
||||
| "name"
|
||||
| "aliases"
|
||||
| "basedOn"
|
||||
| "next"
|
||||
| "link"
|
||||
| "autoRedefine"
|
||||
| "semiHidden"
|
||||
| "qFormat"
|
||||
| "locked"
|
||||
| "tblStylePr"
|
||||
| "lvl"
|
||||
| "start"
|
||||
| "lvlRestart"
|
||||
| "isLgl"
|
||||
| "suff"
|
||||
| "lvlText"
|
||||
| "legacy"
|
||||
| "lvlJc"
|
||||
| "lvlOverride"
|
||||
| "startOverride"
|
||||
| "ilvl"
|
||||
| "binData"
|
||||
)
|
||||
}
|
||||
|
||||
/// The 2003 spelling of an ECMA-376 name: `w:szCs` → `w:sz-cs`, `w:eastAsia` → `w:fareast`,
|
||||
/// `w:vMerge` → `w:vmerge`. Names outside `w:` are kept.
|
||||
fn rename(name: &str) -> String {
|
||||
let Some(local) = name.strip_prefix("w:") else { return name.to_string() };
|
||||
let new = match local {
|
||||
"eastAsia" => "fareast",
|
||||
"vMerge" => "vmerge",
|
||||
"hMerge" => "hmerge",
|
||||
"bCs" => "b-cs",
|
||||
"iCs" => "i-cs",
|
||||
"szCs" => "sz-cs",
|
||||
"hAnsi" => "h-ansi",
|
||||
"firstLine" => "first-line",
|
||||
"lineRule" => "line-rule",
|
||||
"hRule" => "h-rule",
|
||||
"linePitch" => "line-pitch",
|
||||
"firstLineChars" => "first-line-chars",
|
||||
"hangingChars" => "hanging-chars",
|
||||
"leftChars" => "left-chars",
|
||||
"rightChars" => "right-chars",
|
||||
other => other,
|
||||
};
|
||||
format!("w:{new}")
|
||||
}
|
||||
|
||||
/// Elements whose `w:val` is an on/off switch (2003 writes `on` / `off`).
|
||||
fn is_toggle(local: &str) -> bool {
|
||||
matches!(
|
||||
local,
|
||||
"b" | "bCs"
|
||||
| "i"
|
||||
| "iCs"
|
||||
| "caps"
|
||||
| "smallCaps"
|
||||
| "strike"
|
||||
| "dstrike"
|
||||
| "outline"
|
||||
| "shadow"
|
||||
| "emboss"
|
||||
| "imprint"
|
||||
| "noProof"
|
||||
| "snapToGrid"
|
||||
| "vanish"
|
||||
| "webHidden"
|
||||
| "keepNext"
|
||||
| "keepLines"
|
||||
| "pageBreakBefore"
|
||||
| "widowControl"
|
||||
| "suppressLineNumbers"
|
||||
| "suppressAutoHyphens"
|
||||
| "wordWrap"
|
||||
| "contextualSpacing"
|
||||
| "mirrorIndents"
|
||||
| "rtl"
|
||||
| "cs"
|
||||
| "specVanish"
|
||||
| "tblHeader"
|
||||
| "cantSplit"
|
||||
| "noWrap"
|
||||
| "hidden"
|
||||
| "semiHidden"
|
||||
| "qFormat"
|
||||
| "locked"
|
||||
| "tcFitText"
|
||||
| "isLgl"
|
||||
| "bidi"
|
||||
| "overflowPunct"
|
||||
| "kinsoku"
|
||||
| "autoSpaceDE"
|
||||
| "autoSpaceDN"
|
||||
| "adjustRightInd"
|
||||
| "topLinePunct"
|
||||
| "titlePg"
|
||||
)
|
||||
}
|
||||
|
||||
/// The attributes of `e` that 2003 has, renamed: theme references, revision ids and attributes of
|
||||
/// other vocabularies (`w14:`, `r:`…) are dropped.
|
||||
fn attrs(e: &El) -> Vec<(String, String)> {
|
||||
let local_el = e.name.strip_prefix("w:").unwrap_or("");
|
||||
let vml = e.name.starts_with("v:") || e.name.starts_with("o:") || e.name.starts_with("w10:");
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in &e.attrs {
|
||||
let key = match k.split_once(':') {
|
||||
Some(("w", l)) => {
|
||||
let lower = l.to_ascii_lowercase();
|
||||
if lower.starts_with("rsid") || lower.contains("theme") {
|
||||
continue;
|
||||
}
|
||||
rename(k)
|
||||
}
|
||||
Some(("xml", "space")) => k.clone(),
|
||||
Some(("v" | "o" | "w10", _)) if vml => k.clone(),
|
||||
None if vml => k.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let mut val = v.clone();
|
||||
if (key == "w:val" && is_toggle(local_el)) || ((key == "w:default" || key == "w:customStyle") && e.name == "w:style") {
|
||||
val = switch(&val);
|
||||
}
|
||||
out.push((key, val));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An on/off value in 2003's spelling.
|
||||
fn switch(v: &str) -> String {
|
||||
match v {
|
||||
"1" | "true" | "on" => "on".into(),
|
||||
"0" | "false" | "off" => "off".into(),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
@@ -293,12 +293,14 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
w.open("w:comments", &ns_refs);
|
||||
for (cid, c) in &doc.comments {
|
||||
let id = cid.to_string();
|
||||
let author = if c.author.is_empty() { "Unknown" } else { c.author.as_str() };
|
||||
let author = annotation_author(doc, &c.author);
|
||||
let mut a = vec![("w:id", id.as_str()), ("w:author", author)];
|
||||
if !c.date.is_empty() {
|
||||
if !c.date.is_empty() && !doc.settings.remove_date_time {
|
||||
a.push(("w:date", c.date.as_str()));
|
||||
}
|
||||
if !c.initials.is_empty() {
|
||||
if doc.settings.remove_personal_info {
|
||||
a.push(("w:initials", ANONYMOUS_INITIALS));
|
||||
} else if !c.initials.is_empty() {
|
||||
a.push(("w:initials", c.initials.as_str()));
|
||||
}
|
||||
w.open("w:comment", &a);
|
||||
@@ -891,6 +893,13 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
let ns_refs: Vec<(&str, &str)> = ns.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
w.open("w:settings", &ns_refs);
|
||||
w.empty("w:zoom", &[("w:percent", "100")]);
|
||||
// §17.15.1.70 / §17.15.1.69, right after `w:zoom` in CT_Settings.
|
||||
if s.remove_personal_info {
|
||||
w.empty("w:removePersonalInformation", &[]);
|
||||
}
|
||||
if s.remove_date_time {
|
||||
w.empty("w:removeDateAndTime", &[]);
|
||||
}
|
||||
if s.page_color.is_some() {
|
||||
w.empty("w:displayBackgroundShape", &[]);
|
||||
}
|
||||
@@ -1029,8 +1038,28 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// The author the saved file gives a comment or tracked change when the document removes personal
|
||||
/// information on save (`w:removePersonalInformation`), as Word does.
|
||||
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
|
||||
const ANONYMOUS_INITIALS: &str = "A";
|
||||
|
||||
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
|
||||
/// personal information on save, "Unknown" when it has none.
|
||||
pub(crate) fn annotation_author<'a>(doc: &Document, author: &'a str) -> &'a str {
|
||||
if doc.settings.remove_personal_info {
|
||||
ANONYMOUS_AUTHOR
|
||||
} else if author.is_empty() {
|
||||
"Unknown"
|
||||
} else {
|
||||
author
|
||||
}
|
||||
}
|
||||
|
||||
fn core_xml(doc: &Document) -> Vec<u8> {
|
||||
let c = &doc.core;
|
||||
// Remove personal information (§17.15.1.70): no creator or last-modified-by in the file.
|
||||
let personal = |v: &String| if doc.settings.remove_personal_info { String::new() } else { v.clone() };
|
||||
let (creator, last_modified_by) = (personal(&c.creator), personal(&c.last_modified_by));
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"cp:coreProperties",
|
||||
@@ -1045,10 +1074,10 @@ fn core_xml(doc: &Document) -> Vec<u8> {
|
||||
for (tag, v) in [
|
||||
("dc:title", &c.title),
|
||||
("dc:subject", &c.subject),
|
||||
("dc:creator", &c.creator),
|
||||
("dc:creator", &creator),
|
||||
("cp:keywords", &c.keywords),
|
||||
("dc:description", &c.description),
|
||||
("cp:lastModifiedBy", &c.last_modified_by),
|
||||
("cp:lastModifiedBy", &last_modified_by),
|
||||
("cp:category", &c.category),
|
||||
] {
|
||||
if !v.is_empty() {
|
||||
|
||||
@@ -369,8 +369,9 @@ impl Writer<'_> {
|
||||
fn rev_attrs(&mut self, idx: u32) -> (String, String, String) {
|
||||
self.next_rev_id += 1;
|
||||
let r = self.doc.revisions.get(idx as usize);
|
||||
let author = r.map(|r| r.author.clone()).filter(|a| !a.is_empty()).unwrap_or_else(|| "Unknown".into());
|
||||
let date = r.map(|r| r.date.clone()).unwrap_or_default();
|
||||
let author = super::annotation_author(self.doc, r.map_or("", |r| r.author.as_str())).to_string();
|
||||
// Remove date and time (§17.15.1.69): tracked changes are saved without `w:date`.
|
||||
let date = if self.doc.settings.remove_date_time { String::new() } else { r.map(|r| r.date.clone()).unwrap_or_default() };
|
||||
(self.next_rev_id.to_string(), author, date)
|
||||
}
|
||||
|
||||
@@ -535,7 +536,8 @@ impl Writer<'_> {
|
||||
(None, None, None) => None,
|
||||
};
|
||||
let Some(mut root) = root else { return };
|
||||
if ink {
|
||||
let direct_ink = ink && root.name == "w:contentPart";
|
||||
if ink && !direct_ink {
|
||||
Self::ink_transform(&mut root, (graphic.w, graphic.h), (*gw, *gh), float, 0);
|
||||
}
|
||||
let alternate = (ink && root.name == "mc:AlternateContent").then_some(&root);
|
||||
@@ -547,26 +549,30 @@ impl Writer<'_> {
|
||||
self.rev_open(w, props);
|
||||
w.open("w:r", &[]);
|
||||
self.rpr(w, props);
|
||||
if fallback.is_some() {
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", "wpi")]);
|
||||
}
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.raw(&native.unwrap_or(&root).to_xml());
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
w.raw(&fallback.to_xml());
|
||||
w.close("mc:AlternateContent");
|
||||
if direct_ink {
|
||||
w.raw(&root.to_xml());
|
||||
} else {
|
||||
if fallback.is_some() {
|
||||
w.open("mc:AlternateContent", &[]);
|
||||
w.open("mc:Choice", &[("Requires", "wpi")]);
|
||||
}
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
self.drawing_open(w, float, *gw, *gh, &docpr, &name, alt);
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.raw(&native.unwrap_or(&root).to_xml());
|
||||
w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" });
|
||||
w.close("w:drawing");
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
w.raw(&fallback.to_xml());
|
||||
w.close("mc:AlternateContent");
|
||||
}
|
||||
}
|
||||
w.close("w:r");
|
||||
self.rev_close(w, props);
|
||||
@@ -886,7 +892,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -918,7 +924,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -1134,7 +1140,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt);
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
}
|
||||
|
||||
fn table(&mut self, w: &mut W, t: &Table, rels: &mut PartRels, depth: usize) {
|
||||
@@ -1537,7 +1543,7 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText) {
|
||||
pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -1545,6 +1551,9 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
|
||||
if !alt.title.is_empty() {
|
||||
attrs.push(("title", alt.title.as_str()));
|
||||
}
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
|
||||
@@ -178,6 +178,31 @@ fn hyperlink_runs_and_wrappers() {
|
||||
assert_eq!(p[1].plain_text(), "in block sdt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_text_reads_as_unicode_and_round_trips_unchanged() {
|
||||
// Wingdings stored both ways Word writes it: `w:sym` (private-use code) and a plain code in a
|
||||
// run whose font is Wingdings; plus Symbol and Webdings.
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:r><w:t xml:space="preserve">Done </w:t></w:r>
|
||||
<w:r><w:sym w:font="Wingdings" w:char="F0FC"/></w:r>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Wingdings" w:hAnsi="Wingdings"/></w:rPr><w:t>J</w:t></w:r>
|
||||
<w:r><w:sym w:font="Symbol" w:char="F061"/><w:sym w:font="Webdings" w:char="F061"/></w:r>
|
||||
</w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = paras(&d);
|
||||
// Stored as Word stores it…
|
||||
assert_eq!(p[0].plain_text(), "Done \u{F0FC}J\u{F061}\u{F061}");
|
||||
// …read (copied, exported as text) as the symbols shown.
|
||||
assert_eq!(p[0].shown_text(), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
assert_eq!(d.shown_text(wordcraft_doc::StoryRef::Body), "Done \u{2713}\u{263A}\u{3B1}\u{2714}");
|
||||
// A round trip keeps the original characters and fonts.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let q = paras(&again);
|
||||
assert_eq!(q[0].plain_text(), p[0].plain_text());
|
||||
assert_eq!(q[0].shown_text(), p[0].shown_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_content_choice_and_fallback() {
|
||||
let body = r#"
|
||||
@@ -1584,6 +1609,88 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// #886: saving dropped Word's DrawingML shapes (rectangles, ellipses, triangles, arrows,
|
||||
/// stars, connectors and their theme fills). Each kind the report lists is read, saved and read
|
||||
/// back with the same kind, fill, outline, size and position, and the saved part still holds a
|
||||
/// `wps:wsp` with its preset geometry.
|
||||
#[test]
|
||||
fn drawingml_shapes_survive_saving() {
|
||||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
|
||||
let geom = |prst: &str| format!(r#"<a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>"#);
|
||||
let presets = ["rect", "ellipse", "triangle", "rightArrow", "star5"];
|
||||
let mut body: String = presets.iter().map(|p| anchored_wsp(p, 2009775, 819150, &geom(p), style, "<wps:cNvSpPr/>")).collect();
|
||||
body.push_str(&anchored_wsp(
|
||||
"Connector",
|
||||
2362200,
|
||||
390525,
|
||||
r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#,
|
||||
style,
|
||||
"<wps:cNvCnPr/>",
|
||||
));
|
||||
body.push_str(&anchored_wsp(
|
||||
"Text Box",
|
||||
2324100,
|
||||
571500,
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="9525"><a:solidFill><a:srgbClr val="7F7F7F"/></a:solidFill></a:ln>"#,
|
||||
"",
|
||||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Floating text box</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||||
));
|
||||
let shapes = |d: &Document| {
|
||||
paras(d)[0]
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
|
||||
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let d = read_body(&format!("<w:p>{body}</w:p>"));
|
||||
let accent = d.settings.theme_colors[4];
|
||||
let read = shapes(&d);
|
||||
let kinds: Vec<ShapeKind> = read.iter().map(|s| s.0).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
ShapeKind::Rectangle,
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::Triangle,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::StraightConnector,
|
||||
ShapeKind::TextBox
|
||||
]
|
||||
);
|
||||
for s in &read[..5] {
|
||||
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0);
|
||||
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
|
||||
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
|
||||
}
|
||||
assert!(read[5].9, "the connector keeps its arrowhead");
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(shapes(&again), read, "every shape survives saving");
|
||||
assert_eq!(part_text_box(&again), "Floating text box");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
|
||||
assert_eq!(xml.matches("<wps:wsp>").count(), 7);
|
||||
for p in presets.iter().chain(&["straightConnector1"]) {
|
||||
assert!(xml.contains(&format!(r#"<a:prstGeom prst="{p}">"#)), "{p} is written");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of the first text box in `d`.
|
||||
fn part_text_box(d: &Document) -> String {
|
||||
let story = paras(d)[0].objects.iter().find_map(|o| match o {
|
||||
InlineObject::Shape { kind: ShapeKind::TextBox, story, .. } => *story,
|
||||
_ => None,
|
||||
});
|
||||
story.and_then(|id| d.parts.get(&id)).map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_fonts_survive_a_round_trip() {
|
||||
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
|
||||
@@ -1674,6 +1781,36 @@ fn native_ink_survives_unrelated_edit_and_two_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_native_ink_content_part_survives_unrelated_edit_and_two_saves() {
|
||||
let body = r#"<w:p><w:r><w:t>Original</w:t></w:r><w:r><w:contentPart r:id="rInk"/></w:r></w:p>"#;
|
||||
let mut d = wordcraft_docx::read(&docx(
|
||||
body,
|
||||
&[("rInk", "customXml", "ink/ink1.xml")],
|
||||
&[("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)],
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1, "direct native ink must remain a model object");
|
||||
let p = std::sync::Arc::make_mut(&mut d.body[0]);
|
||||
let Block::Para(p) = p else { panic!() };
|
||||
p.delete(0, 8).unwrap();
|
||||
p.insert_text(0, "Edited", &Default::default()).unwrap();
|
||||
for _ in 0..2 {
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let files = unzip(&out);
|
||||
let body = text(&files, "word/document.xml");
|
||||
assert!(body.contains("<w:contentPart"), "direct content part must stay direct: {body}");
|
||||
let [id] = attr_values(&body, "w:contentPart", "r:id").try_into().unwrap();
|
||||
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
|
||||
assert!(kind.ends_with("/customXml"));
|
||||
let part = part_at("word", &target);
|
||||
assert_eq!(text(&files, &part), INK_XML);
|
||||
assert!(text(&files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
|
||||
d = wordcraft_docx::read(&out).unwrap();
|
||||
assert_eq!(graphics(&d).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
|
||||
for alias in [false, true] {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Privacy settings (#1062): `w:removePersonalInformation` and `w:removeDateAndTime`
|
||||
//! (ECMA-376 Part 1 §17.15.1.69–70) are kept on save and honoured by the writer. Synthetic
|
||||
//! fixtures written for these tests from the spec; the names are made up.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
|
||||
fn zip(entries: &[(&str, String)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, text) in entries {
|
||||
z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, String> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut s = String::new();
|
||||
if f.read_to_string(&mut s).is_ok() {
|
||||
parts.insert(f.name().to_string(), s);
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// A package with two named commenters, a tracked insertion and deletion, the document
|
||||
/// properties' creator and last-modified-by, and `settings` inside `w:settings`.
|
||||
fn fixture(settings: &str) -> Vec<u8> {
|
||||
let body = r#"<w:p><w:commentRangeStart w:id="1"/><w:r><w:t>Clause one.</w:t></w:r><w:commentRangeEnd w:id="1"/><w:r><w:commentReference w:id="1"/></w:r></w:p>
|
||||
<w:p><w:commentRangeStart w:id="2"/><w:ins w:id="10" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added</w:t></w:r></w:ins><w:del w:id="11" w:author="Ona Vettel" w:date="2026-03-05T05:06:07Z"><w:r><w:delText>Removed</w:delText></w:r></w:del><w:commentRangeEnd w:id="2"/><w:r><w:commentReference w:id="2"/></w:r></w:p>"#;
|
||||
let comments = format!(
|
||||
r#"<w:comments {W_NS}><w:comment w:id="1" w:author="Quill Ramsey" w:initials="QR" w:date="2026-03-01T10:00:00Z"><w:p><w:r><w:t>Check this.</w:t></w:r></w:p></w:comment><w:comment w:id="2" w:author="Ona Vettel" w:initials="OV" w:date="2026-03-02T10:00:00Z"><w:p><w:r><w:t>Agreed.</w:t></w:r></w:p></w:comment></w:comments>"#
|
||||
);
|
||||
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Draft terms</dc:title><dc:creator>Quill Ramsey</dc:creator><cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy></cp:coreProperties>"#;
|
||||
let root_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#
|
||||
);
|
||||
let doc_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}comments" Target="comments.xml"/><Relationship Id="rId2" Type="{RT}settings" Target="settings.xml"/></Relationships>"#
|
||||
);
|
||||
zip(&[
|
||||
("_rels/.rels", root_rels),
|
||||
("word/document.xml", format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#)),
|
||||
("word/_rels/document.xml.rels", doc_rels),
|
||||
("word/comments.xml", comments),
|
||||
("word/settings.xml", format!(r#"<w:settings {W_NS}>{settings}</w:settings>"#)),
|
||||
("docProps/core.xml", core.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
const NAMES: [&str; 4] = ["Quill Ramsey", "Ona Vettel", "\"QR\"", "\"OV\""];
|
||||
|
||||
/// Every part of the saved package, joined.
|
||||
fn saved(doc: &wordcraft_doc::Document) -> (BTreeMap<String, String>, String) {
|
||||
let parts = unzip(&wordcraft_docx::write(doc).unwrap());
|
||||
let all = parts.values().cloned().collect::<Vec<_>>().join("\n");
|
||||
(parts, all)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_personal_information_and_dates_on_save() {
|
||||
let doc = wordcraft_docx::read(&fixture("<w:removePersonalInformation/><w:removeDateAndTime/>")).unwrap();
|
||||
assert!(doc.settings.remove_personal_info && doc.settings.remove_date_time);
|
||||
let before = doc.clone();
|
||||
let (parts, all) = saved(&doc);
|
||||
// The open document keeps its real names and dates.
|
||||
assert_eq!(doc, before);
|
||||
assert_eq!(doc.core.creator, "Quill Ramsey");
|
||||
assert!(doc.comments.values().any(|c| c.author == "Ona Vettel" && !c.date.is_empty()));
|
||||
// No name, initials or annotation date anywhere in the file.
|
||||
for name in NAMES {
|
||||
assert!(!all.contains(name), "{name} saved");
|
||||
}
|
||||
assert!(!all.contains("w:date="), "annotation date saved");
|
||||
assert!(!all.contains("2026-03-0"), "annotation date saved");
|
||||
let comments = &parts["word/comments.xml"];
|
||||
assert_eq!(comments.matches(r#"w:author="Author""#).count(), 2, "{comments}");
|
||||
assert_eq!(comments.matches(r#"w:initials="A""#).count(), 2, "{comments}");
|
||||
let body = &parts["word/document.xml"];
|
||||
assert!(body.contains("<w:ins ") && body.contains("<w:del "));
|
||||
assert_eq!(body.matches(r#"w:author="Author""#).count(), 2, "{body}");
|
||||
let core = &parts["docProps/core.xml"];
|
||||
assert!(!core.contains("dc:creator") && !core.contains("cp:lastModifiedBy"), "{core}");
|
||||
assert!(core.contains("Draft terms"), "title kept: {core}");
|
||||
// Both settings are written back (in CT_Settings order, after zoom), so Word keeps honouring them.
|
||||
let settings = &parts["word/settings.xml"];
|
||||
let zoom = settings.find("w:zoom").unwrap();
|
||||
let personal = settings.find("<w:removePersonalInformation/>").unwrap();
|
||||
let dates = settings.find("<w:removeDateAndTime/>").unwrap();
|
||||
assert!(zoom < personal && personal < dates, "{settings}");
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert!(again.settings.remove_personal_info && again.settings.remove_date_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_privacy_setting_works_alone() {
|
||||
// Dates only: names stay.
|
||||
let doc = wordcraft_docx::read(&fixture("<w:removeDateAndTime/>")).unwrap();
|
||||
let (parts, all) = saved(&doc);
|
||||
assert!(!all.contains("w:date="));
|
||||
assert!(all.contains("Quill Ramsey") && all.contains("Ona Vettel"));
|
||||
assert!(parts["docProps/core.xml"].contains("<dc:creator>Quill Ramsey</dc:creator>"));
|
||||
assert!(!parts["word/settings.xml"].contains("removePersonalInformation"));
|
||||
// Names only: dates stay.
|
||||
let doc = wordcraft_docx::read(&fixture(r#"<w:removePersonalInformation w:val="1"/>"#)).unwrap();
|
||||
let (_, all) = saved(&doc);
|
||||
for name in NAMES {
|
||||
assert!(!all.contains(name), "{name} saved");
|
||||
}
|
||||
assert_eq!(all.matches("w:date=").count(), 4, "two comments and two changes keep their dates");
|
||||
// Off (explicitly or absent): everything is saved as it was.
|
||||
for settings in ["", r#"<w:removePersonalInformation w:val="0"/><w:removeDateAndTime w:val="false"/>"#] {
|
||||
let doc = wordcraft_docx::read(&fixture(settings)).unwrap();
|
||||
assert!(!doc.settings.remove_personal_info && !doc.settings.remove_date_time);
|
||||
let (parts, all) = saved(&doc);
|
||||
assert!(all.contains("Quill Ramsey") && all.contains(r#"w:initials="OV""#));
|
||||
assert_eq!(all.matches("w:date=").count(), 4);
|
||||
assert!(parts["docProps/core.xml"].contains("<cp:lastModifiedBy>Ona Vettel</cp:lastModifiedBy>"));
|
||||
assert!(!parts["word/settings.xml"].contains("removeDateAndTime"));
|
||||
}
|
||||
}
|
||||
@@ -108,3 +108,90 @@ fn hostile_links_read_without_chains_running_wild() {
|
||||
let b = r.parts.get(&got[0]).unwrap().body;
|
||||
assert_eq!((b.vert, b.anchor), (TextVert::Vert, VAlign::Center));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
// ECMA-376 Part 4 §19.1.2.22: `inset="l,t,r,b"`, each one left out keeping the default
|
||||
// (0.1in, 0.05in); `mso-fit-shape-to-text` resizes the shape to its text; `v-text-anchor`
|
||||
// places the text between the box's top and bottom.
|
||||
let content = r#"<w:txbxContent><w:p><w:r><w:t>Boxed</w:t></w:r></w:p></w:txbxContent>"#;
|
||||
let shape = |style: &str, textbox: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:pict><v:shape style="width:100pt;height:40pt;{style}"><v:textbox {textbox}>{content}</v:textbox></v:shape></w:pict></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let body = [
|
||||
shape("", r#"inset="0,0,0,0""#),
|
||||
shape("v-text-anchor:middle", r#"inset="1mm,,2pt" style="mso-fit-shape-to-text:T""#),
|
||||
shape("", ""),
|
||||
// A bare number is in EMUs; one that doesn't parse keeps the default; negative is 0;
|
||||
// too large is the most a margin may be.
|
||||
shape("v-text-anchor:bottom-baseline", r#"inset="12700,bogus,-5pt,99in""#),
|
||||
// `o:insetmode="auto"` leaves the margins to the application.
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
assert_eq!(again, bodies);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_drawings_stay_hidden() {
|
||||
// VML `visibility:hidden` (floating or inline) and DrawingML `wp:docPr/@hidden` (also a group
|
||||
// member's `wps:cNvPr/@hidden`) mark a drawing hidden; it is kept, and saved hidden.
|
||||
let vml = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:hidden"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
|
||||
let shown = r#"<w:p><w:r><w:pict><v:shape style="position:absolute;width:50pt;height:50pt;visibility:visible"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
|
||||
let inline_vml = r#"<w:p><w:r><w:pict><v:rect style="width:20pt;height:20pt;visibility:hidden"/></w:pict></w:r></w:p>"#;
|
||||
let dml = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><wp:docPr id="7" name="Box" hidden="1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p/></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||||
let member = |id: u32, hidden: &str| {
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"{hidden}/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="635000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"/></wps:spPr><wps:bodyPr/></wps:wsp>"#
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="8" name="Group"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="1270000" cy="635000"/></a:xfrm></wpg:grpSpPr>{}{}</wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
|
||||
member(9, r#" hidden="1""#),
|
||||
member(10, "")
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><w:body>{vml}{shown}{inline_vml}{dml}{group}</w:body></w:document>"#
|
||||
);
|
||||
let rels = br#"<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>"#;
|
||||
// Each drawing's flag, a group's members' after it.
|
||||
let hidden = |d: &Document| {
|
||||
let mut out = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| {
|
||||
out.extend(o.frame().map(|(_, _, f)| f.hidden));
|
||||
if let InlineObject::Group { children, .. } = o {
|
||||
out.extend(children.iter().filter_map(|c| c.obj.frame()).map(|(_, _, f)| f.hidden));
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let want = [true, false, true, true, false, true, false];
|
||||
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
assert_eq!(hidden(&d), want);
|
||||
let saved = wordcraft_docx::write(&d).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
let marked =
|
||||
|tag: &str| xml.split(&format!("<{tag} ")).skip(1).filter(|a| a.split('>').next().is_some_and(|a| a.contains(r#"hidden="1""#))).count();
|
||||
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
|
||||
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Saving as Word 2003 XML (#1081): documents built in code, written as WordprocessingML 2003
|
||||
//! and read back with the 2003 reader.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::props::{Align, CharProps, NumRef, Rgb, TextColor};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
|
||||
use wordcraft_docx::{XmlKind, read_xml, sniff_xml, write_wordml2003};
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 80, y as u8 * 120, 200, 255]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
fn part_text(doc: &Document, id: Option<u32>) -> String {
|
||||
id.and_then(|i| doc.parts.get(&i))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sample() -> Document {
|
||||
let mut d = Document::new();
|
||||
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 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()))]);
|
||||
d.comments.insert(3, Comment { author: "Bo".into(), part: com, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
d.last_section.headers.default = Some(head);
|
||||
|
||||
let mut p = Paragraph::new();
|
||||
for (t, c) in [
|
||||
("Plain ", CharProps::default()),
|
||||
(
|
||||
"loud",
|
||||
CharProps { bold: Some(true), italic: Some(true), size: Some(14.0), color: Some(TextColor::Rgb(Rgb(255, 0, 0))), ..Default::default() },
|
||||
),
|
||||
(" link", CharProps { link: Some("https://example.org/beans?a=1&b=2".into()), ..Default::default() }),
|
||||
] {
|
||||
let off = p.len();
|
||||
p.insert_text(off, t, &c).unwrap();
|
||||
}
|
||||
p.props.align = Some(Align::Center);
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.props.space_after = Some(12.0);
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 3 }, &CharProps::default()).unwrap();
|
||||
let end = p.len();
|
||||
p.insert_object(end, InlineObject::CommentEnd { id: 3 }, &CharProps::default()).unwrap();
|
||||
let at = p.text.find("loud").unwrap();
|
||||
p.insert_object(at, InlineObject::NoteRef { kind: NoteKind::Footnote, id: foot, custom: String::new() }, &CharProps::default()).unwrap();
|
||||
let mut blocks = vec![para_block(p)];
|
||||
|
||||
let mut li = Paragraph::with_text("listed", CharProps { ins: Some(0), ..Default::default() });
|
||||
li.props.numbering = Some(NumRef { num, level: 0 });
|
||||
blocks.push(para_block(li));
|
||||
|
||||
let mut img = Paragraph::new();
|
||||
img.insert_object(
|
||||
0,
|
||||
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
blocks.push(para_block(img));
|
||||
|
||||
let mut t = Table::new(2, 2, 300.0);
|
||||
t.rows[0].cells[0] = Cell::with_text("A1");
|
||||
t.rows[0].cells[1] = Cell::with_text("B1");
|
||||
t.rows[1].cells[0].props.shading = Some(Rgb(0, 128, 0));
|
||||
blocks.push(Arc::new(Block::Table(t)));
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
d.body = blocks;
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_the_2003_reader() {
|
||||
let d = sample();
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
let text = std::str::from_utf8(&xml).expect("utf-8");
|
||||
assert!(text.contains("<w:wordDocument") && text.contains("http://schemas.microsoft.com/office/word/2003/wordml"));
|
||||
assert!(text.contains("<w:binData") && text.contains("<w:hlink w:dest=\"https://example.org/beans?a=1&b=2\""));
|
||||
assert!(text.contains("<w:footnote>") && text.contains("<wx:sect>") && text.contains("<aml:annotation"));
|
||||
assert!(!text.contains("w:szCs") && !text.contains("<w:drawing"));
|
||||
if std::env::var_os("WML2003_DUMP").is_some() {
|
||||
println!("{text}");
|
||||
}
|
||||
assert_eq!(sniff_xml(&xml), XmlKind::WordMl2003);
|
||||
|
||||
let back = read_xml(&xml).expect("read");
|
||||
assert_eq!(back.core.title, "Garden <notes> & more");
|
||||
assert_eq!(back.core.creator, "Ada");
|
||||
let ps = paras(&back);
|
||||
assert_eq!(ps[0].plain_text(), "Plain loud link");
|
||||
assert_eq!(ps[0].props.align, Some(Align::Center));
|
||||
assert_eq!(ps[0].props.indent_left, Some(36.0));
|
||||
assert_eq!(ps[0].props.space_after, Some(12.0));
|
||||
let c = ps[0].props_of_char(ps[0].text.find("loud").unwrap());
|
||||
assert_eq!((c.bold, c.italic, c.size), (Some(true), Some(true), Some(14.0)));
|
||||
assert!(c.color.is_some());
|
||||
assert_eq!(ps[0].props_at(ps[0].text.find("link").unwrap()).link.as_deref(), Some("https://example.org/beans?a=1&b=2"));
|
||||
let note = ps[0].objects.iter().find_map(|o| match o {
|
||||
InlineObject::NoteRef { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(part_text(&back, note), "Sown in May.");
|
||||
let comment = back.comments.values().next().expect("comment");
|
||||
assert_eq!((comment.author.as_str(), part_text(&back, Some(comment.part)).as_str()), ("Bo", "Too short?"));
|
||||
|
||||
let listed = ps[1];
|
||||
assert!(listed.props.numbering.is_some(), "list paragraph keeps its list");
|
||||
let ins = listed.props_of_char(0).ins.and_then(|i| back.revisions.get(i as usize));
|
||||
assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada"));
|
||||
|
||||
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
|
||||
assert!(matches!(pic, Some(InlineObject::Image { w, h, alt, .. }) if *w == 30.0 && *h == 20.0 && alt == "dot"));
|
||||
assert_eq!(back.media.len(), 1);
|
||||
|
||||
let table = back.body.iter().find_map(|b| match b.as_ref() {
|
||||
Block::Table(t) => Some(t),
|
||||
_ => None,
|
||||
});
|
||||
let table = table.expect("table");
|
||||
assert_eq!(table.rows.len(), 2);
|
||||
assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1"));
|
||||
assert_eq!(table.rows[1].cells[0].props.shading, Some(Rgb(0, 128, 0)));
|
||||
assert_eq!(part_text(&back, back.last_section.headers.default), "Head");
|
||||
}
|
||||
|
||||
/// Features 2003 XML can't carry drop out without an error, and the text stays.
|
||||
#[test]
|
||||
fn control_characters_and_unsupported_content_do_not_break_the_file() {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("a\u{1}b\u{FFFE}c <&> \"q\"", CharProps { size: Some(f32::NAN), ..Default::default() });
|
||||
p.insert_object(
|
||||
0,
|
||||
InlineObject::Opaque { format: "docx".into(), xml: "<w:r><w:t>raw</w:t></w:r>".into(), text: "raw".into() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
let text = String::from_utf8(xml).expect("utf-8");
|
||||
assert!(!text.contains('\u{1}') && !text.contains('\u{FFFE}'));
|
||||
assert!(!text.contains("<w:drawing") && !text.contains("<mc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_and_deep_documents_write() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text(&"word ".repeat(400_000), CharProps::default()))];
|
||||
let xml = write_wordml2003(&d).expect("write");
|
||||
assert!(read_xml(&xml).is_ok());
|
||||
|
||||
// Tables nested deeper than the writers go: no panic, whatever comes out.
|
||||
let mut inner = Cell::with_text("deep");
|
||||
for _ in 0..60 {
|
||||
let mut t = Table::new(1, 1, 100.0);
|
||||
t.rows[0].cells[0] = inner;
|
||||
inner = Cell::empty();
|
||||
inner.blocks = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = inner.blocks;
|
||||
if let Ok(xml) = write_wordml2003(&d) {
|
||||
let _ = read_xml(&xml);
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,9 @@ impl Checker<'_> {
|
||||
let Some(o) = p.object_at(off).filter(|o| o.is_drawing()) else { continue };
|
||||
let pos = at(path, off);
|
||||
let select = json!({"anchor": pos, "focus": at(path, off + OBJ.len_utf8())});
|
||||
// Ink: every stroke is its own object, so asking about each would bury the report.
|
||||
if o.is_decorative() || o.ink().is_some() {
|
||||
// Ink: every stroke is its own object, so asking about each would bury the report. A
|
||||
// hidden drawing isn't shown, so nobody reads it.
|
||||
if o.is_decorative() || o.ink().is_some() || o.frame().is_some_and(|(_, _, f)| f.hidden) {
|
||||
continue;
|
||||
}
|
||||
let titled = o.frame().is_some_and(|(_, _, f)| !f.alt.title.trim().is_empty());
|
||||
|
||||
@@ -211,7 +211,7 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
|
||||
}
|
||||
s.clipboard_text = rows.join("\n");
|
||||
s.clipboard = Some(Fragment { blocks, parts });
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
Ok(super::edit::copy_result(s))
|
||||
}
|
||||
|
||||
/// Delete each row's piece (bottom up, so earlier positions stay valid) and leave the caret at
|
||||
|
||||
@@ -460,20 +460,20 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let at = place(s, &layout, obj, page, (fx, fy), first[1])?;
|
||||
let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?;
|
||||
// Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step).
|
||||
let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None };
|
||||
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape}))
|
||||
}
|
||||
|
||||
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
|
||||
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
|
||||
/// under `anchor` (positioned from its column and top), or, where no paragraph starts on the
|
||||
/// page, to the nearest line and positioned on the page. Returns where its character went.
|
||||
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
|
||||
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y, StoryRef::Body) {
|
||||
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor: (f32, f32)) -> Result<Pos, CmdError> {
|
||||
let (at, on_page) = match super::objects::anchor_paragraph(&s.doc, layout, page, anchor, StoryRef::Body) {
|
||||
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
|
||||
None => (
|
||||
super::objects::nearest_line(layout, page, anchor_y, StoryRef::Body)
|
||||
super::objects::nearest_line(layout, page, anchor.1, StoryRef::Body)
|
||||
.ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?,
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -159,10 +159,20 @@ fn copy(s: &mut Session, _: &Value) -> CmdResult {
|
||||
return Ok(json!({"text": ""}));
|
||||
}
|
||||
let f = s.doc.copy_range(&a, &b);
|
||||
s.clipboard_text = f.plain_text();
|
||||
s.clipboard_text = f.clipboard_text();
|
||||
s.clip_history.push(&f, &s.clipboard_text);
|
||||
s.clipboard = Some(f);
|
||||
Ok(json!({"text": s.clipboard_text}))
|
||||
Ok(copy_result(s))
|
||||
}
|
||||
|
||||
/// What Copy and Cut return: the clipboard's text, and HTML with the equations as MathML when
|
||||
/// the copy holds any (#816), for the host to put on the system clipboard too.
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) if f.has_equation() => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
_ => String::new(),
|
||||
};
|
||||
if s.clipboard_html.is_empty() { json!({"text": s.clipboard_text}) } else { json!({"text": s.clipboard_text, "html": s.clipboard_html}) }
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -7,6 +7,11 @@ use super::{pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// Longest user name kept, in characters; longer names are cut, not refused.
|
||||
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
|
||||
pub const FORMAT_WORD_2003: &str = "wordml2003";
|
||||
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
|
||||
pub const FORMAT_FLAT_OPC: &str = "flatopc";
|
||||
|
||||
pub const MAX_AUTHOR_CHARS: usize = 255;
|
||||
|
||||
/// Sets the user name that new tracked changes and comments are recorded under. The name is
|
||||
@@ -35,9 +40,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save)
|
||||
.key("Mod+S / Shift+F12")
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup)}"#)
|
||||
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool (as file.save)}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
|
||||
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
|
||||
// to undo; it marks the document as changed itself.
|
||||
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
|
||||
@@ -85,6 +90,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
|
||||
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
|
||||
.pure(),
|
||||
// Trust Center › Privacy Options: the document's `w:removePersonalInformation` and
|
||||
// `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062).
|
||||
// Changing them is a document change (undoable, refused on a protected document); reading
|
||||
// them is `file.privacy`, which changes nothing.
|
||||
CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| {
|
||||
let personal = p::bool(v, "removePersonalInfo");
|
||||
let date_time = p::bool(v, "removeDateTime");
|
||||
if personal.is_none() && date_time.is_none() {
|
||||
return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into()));
|
||||
}
|
||||
if let Some(on) = personal {
|
||||
s.doc.settings.remove_personal_info = on;
|
||||
}
|
||||
if let Some(on) = date_time {
|
||||
s.doc.settings.remove_date_time = on;
|
||||
}
|
||||
Ok(privacy(s))
|
||||
})
|
||||
.params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#),
|
||||
CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s)))
|
||||
.params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#)
|
||||
.pure(),
|
||||
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
|
||||
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
|
||||
let story = super::story_param(s, v);
|
||||
@@ -113,6 +140,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them.
|
||||
fn privacy(s: &Session) -> Value {
|
||||
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
|
||||
}
|
||||
|
||||
fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let doc = match p::str(v, "template").unwrap_or("blank") {
|
||||
"sample" => crate::sample::sample_document(),
|
||||
@@ -199,11 +231,22 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
s.password = Some(crate::io::Password::new(pw));
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let mut opts = export_options(s, v);
|
||||
match v.get("format").filter(|f| !f.is_null()) {
|
||||
None => {
|
||||
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
|
||||
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
|
||||
}
|
||||
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
|
||||
Some(_) => {
|
||||
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
|
||||
}
|
||||
}
|
||||
s.stamp_save();
|
||||
let password = s.password.as_ref().map(crate::io::Password::as_str);
|
||||
crate::io::save_path_with(&path, &s.doc, password, &export_options(s, v)).map_err(CmdError::Failed)?;
|
||||
crate::io::save_path_with(&path, &s.doc, password, &opts).map_err(CmdError::Failed)?;
|
||||
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = false;
|
||||
@@ -211,6 +254,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
|
||||
}
|
||||
|
||||
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_word_2003_file(path: &std::path::Path) -> bool {
|
||||
use std::io::Read;
|
||||
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(f) = std::fs::File::open(path) else { return false };
|
||||
let mut head = Vec::new();
|
||||
// The root element and its namespaces come first.
|
||||
if f.take(64 * 1024).read_to_end(&mut head).is_err() {
|
||||
return false;
|
||||
}
|
||||
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn is_word_2003_file(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "path").is_none() {
|
||||
s.ui_requests.push(json!({"open": "saveAs"}));
|
||||
@@ -223,7 +287,7 @@ fn save_as(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
|
||||
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
|
||||
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments }
|
||||
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
@@ -232,7 +296,11 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
|
||||
let l = s.export_layout_with(export_options(s, v).markup);
|
||||
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
|
||||
// A page image is the printed page: headers and footers at full strength. Dimming them is
|
||||
// the on-screen cue while editing the body, not part of the page.
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.dim_header = false;
|
||||
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
|
||||
let png = img.to_png();
|
||||
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
|
||||
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
|
||||
|
||||
@@ -361,11 +361,7 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
||||
|
||||
fn color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "color")?;
|
||||
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
|
||||
TextColor::Auto
|
||||
} else {
|
||||
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
|
||||
};
|
||||
let tc = TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?;
|
||||
apply(s, &|x| x.color = Some(tc))
|
||||
}
|
||||
|
||||
|
||||
@@ -201,20 +201,26 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
let (pw, ph) =
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
Some([0xFF, 0xD8, ..]) => "jpeg",
|
||||
Some([b'G', b'I', b'F', _]) => "gif",
|
||||
Some([b'R', b'I', b'F', b'F']) => "webp",
|
||||
Some([b'B', b'M', ..]) => "bmp",
|
||||
_ => "png",
|
||||
};
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Natural size at 96 ppi, fitted to the text width.
|
||||
// Fitted to the text width.
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
w *= k;
|
||||
|
||||
@@ -701,7 +701,8 @@ fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, x: f32, y: f32, (w,
|
||||
// Keep a corner of it on the page.
|
||||
let x = x.max(12.0 - w).min(pg.w - 12.0);
|
||||
let y = y.max(12.0 - h).min(pg.h.min(MAX_OFFSET) - 12.0);
|
||||
let (anchor, on_page) = match anchor_paragraph(&layout, page, y, story) {
|
||||
// The paragraph under the middle of its top edge: in the column it mostly lands in.
|
||||
let (anchor, on_page) = match anchor_paragraph(&s.doc, &layout, page, (x + w / 2.0, y), story) {
|
||||
Some(path) => (Pos { story, path, off: 0 }, false),
|
||||
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
|
||||
};
|
||||
@@ -801,22 +802,50 @@ fn move_with_text(s: &mut Session, with_text: bool) -> CmdResult {
|
||||
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// The top-level paragraph of `story` under `y` on `page`: the last one starting at or above
|
||||
/// it (else the first starting on the page). Body paragraphs come from the page's items;
|
||||
/// header/footer ones from its header and footer.
|
||||
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32, story: StoryRef) -> Option<wordcraft_doc::Path> {
|
||||
/// The top-level paragraph of `story` under `(x, y)` on `page`: in the column under `x` (from
|
||||
/// the section's column geometry), the last one starting at or above `y` (else the first
|
||||
/// starting in that column; else, with no paragraph starting in that column, the last starting
|
||||
/// at or above `y` anywhere on the page, else the first). Body paragraphs come from the page's
|
||||
/// items; header/footer ones from its header and footer. The column matters (#884): layout
|
||||
/// places a floating object when it lays out its anchor, so text laid out before that (an
|
||||
/// earlier column) can't wrap around it. (Nor do lines continuing a paragraph from the column
|
||||
/// before: layout breaks them once, in the column the paragraph starts in.)
|
||||
pub(crate) fn anchor_paragraph(
|
||||
doc: &wordcraft_doc::Document,
|
||||
layout: &wordcraft_layout::DocLayout,
|
||||
page: usize,
|
||||
(x, y): (f32, f32),
|
||||
story: StoryRef,
|
||||
) -> Option<wordcraft_doc::Path> {
|
||||
let pg = layout.pages.get(page)?;
|
||||
let items: Box<dyn Iterator<Item = &wordcraft_layout::Placed> + '_> = match story {
|
||||
StoryRef::Body => Box::new(pg.items.iter()),
|
||||
StoryRef::Part(_) => Box::new(pg.header.iter().chain(pg.footer.iter())),
|
||||
};
|
||||
let starts: Vec<(&wordcraft_doc::Path, f32)> = items
|
||||
// Top-level text on the page: (path, left, top, starts the paragraph).
|
||||
let pieces: Vec<(&wordcraft_doc::Path, f32, f32, bool)> = items
|
||||
.filter_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { story: s, path, l0: 0, y: top, .. } if *s == story && path.depth() == 0 => Some((path, *top)),
|
||||
wordcraft_layout::Placed::Lines { story: s, path, l0, x, y: top, .. } if *s == story && path.depth() == 0 => {
|
||||
Some((path, *x, *top, *l0 == 0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
starts.iter().rev().find(|(_, top)| *top <= y + 0.5).or(starts.first()).map(|(p, _)| (*p).clone())
|
||||
let starts: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3).map(|p| (p.0, p.2)).collect();
|
||||
// The page's columns (left, right), from its section's column geometry (headers and footers
|
||||
// span the text area); the column under `x` is the rightmost one starting at or left of it.
|
||||
let boxes = match story {
|
||||
StoryRef::Body => doc.sections().get(pg.section).map(|(_, sp)| sp.column_boxes()).unwrap_or_default(),
|
||||
StoryRef::Part(_) => Vec::new(),
|
||||
};
|
||||
let mut cols: Vec<(f32, f32)> =
|
||||
boxes.iter().map(|(cx, w)| (pg.body.x + cx, pg.body.x + cx + w)).filter(|c| c.0.is_finite() && c.1.is_finite()).collect();
|
||||
cols.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let col_of = |px: f32| cols.iter().rposition(|c| c.0 <= px + 0.5).unwrap_or(0);
|
||||
let col = col_of(x);
|
||||
let in_col: Vec<(&wordcraft_doc::Path, f32)> = pieces.iter().filter(|p| p.3 && col_of(p.1) == col).map(|p| (p.0, p.2)).collect();
|
||||
let pick = |v: &[(&wordcraft_doc::Path, f32)]| v.iter().rev().find(|(_, top)| *top <= y + 0.5).or(v.first()).map(|(p, _)| (*p).clone());
|
||||
pick(&in_col).or_else(|| pick(&starts))
|
||||
}
|
||||
|
||||
/// The start of the top-level line of `story` on `page` nearest to `y`.
|
||||
@@ -1342,7 +1371,8 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult {
|
||||
InlineObject::Group { .. } => ("group", format!("Group {}", i + 1)),
|
||||
_ => ("object", format!("Object {}", i + 1)),
|
||||
};
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)})
|
||||
let hidden = o.frame().is_some_and(|(_, _, f)| f.hidden);
|
||||
json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o), "hidden": hidden})
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
@@ -1809,4 +1839,120 @@ mod tests {
|
||||
let t = s.doc.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t.clone()) } else { None }).unwrap();
|
||||
assert_eq!((t.props.caption.as_deref(), t.props.description.as_deref()), (Some("Prices"), None));
|
||||
}
|
||||
|
||||
/// The lines of `page` beside `r` vertically (top above its bottom, bottom below its top).
|
||||
fn lines_beside(s: &mut Session, page: usize, r: wordcraft_geom::Rect) -> Vec<(f32, f32, f32)> {
|
||||
let layout = s.layout();
|
||||
wordcraft_layout::hit::page_lines(&layout.pages[page], StoryRef::Body)
|
||||
.iter()
|
||||
.filter(|l| l.top < r.bottom() - 0.5 && l.bottom > r.y + 0.5)
|
||||
.map(|l| (l.top, l.left, l.right))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// #884: a Square-wrapped shape dragged across a two-column page: the text in whichever
|
||||
/// column it lands in wraps around it there, and its old place closes up.
|
||||
#[test]
|
||||
fn dragged_square_shape_wraps_text_in_its_new_column() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
for i in 0..40 {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": format!("Paragraph {i} has words enough to run over two or three lines of a narrow column.")}))
|
||||
.unwrap();
|
||||
}
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
|
||||
s.run("arrange.bounds", &json!({"width": 80, "height": 60})).unwrap();
|
||||
let cols: Vec<(f32, f32)> = {
|
||||
let layout = s.layout();
|
||||
let mut v: Vec<(f32, f32)> =
|
||||
wordcraft_layout::hit::page_lines(&layout.pages[0], StoryRef::Body).iter().map(|l| (l.left, l.right)).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v.dedup_by(|a, b| (a.0 - b.0).abs() < 1.0);
|
||||
v
|
||||
};
|
||||
assert_eq!(cols.len(), 2, "two columns: {cols:?}");
|
||||
let mid = |c: (f32, f32)| (c.0 + c.1) / 2.0 - 40.0;
|
||||
let wrapped = |s: &mut Session, r: wordcraft_geom::Rect| {
|
||||
let beside = lines_beside(s, 0, r);
|
||||
assert!(!beside.is_empty(), "text beside {r:?}");
|
||||
for (top, left, right) in beside {
|
||||
assert!(right <= r.x + 0.5 || left >= r.right() - 0.5, "line at {top} ({left}..{right}) runs into {r:?}");
|
||||
}
|
||||
};
|
||||
let full = |s: &mut Session, r: wordcraft_geom::Rect, c: (f32, f32)| {
|
||||
for (top, left, right) in lines_beside(s, 0, r) {
|
||||
if left < c.1 && right > c.0 {
|
||||
assert!((left - c.0).abs() < 0.5 && (right - c.1).abs() < 0.5, "line at {top} ({left}..{right}) still kept clear in {c:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
// Into the second column, then back into the first, then again to the second, lower.
|
||||
for (col, y, from) in [(1, 300.0, None), (0, 200.0, Some(1)), (1, 400.0, Some(0))] {
|
||||
let old = object_area(&mut s).rect;
|
||||
s.run("arrange.bounds", &json!({"page": 0, "x": mid(cols[col]), "y": y})).unwrap();
|
||||
let o = object_area(&mut s);
|
||||
assert_eq!((o.page, o.rect.x, o.rect.y), (0, mid(cols[col]), y), "dropped where asked");
|
||||
wrapped(&mut s, o.rect);
|
||||
if let Some(c) = from {
|
||||
full(&mut s, old, cols[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two columns of `n` paragraphs.
|
||||
fn two_column_doc(n: usize) -> Session {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
for i in 0..n {
|
||||
if i > 0 {
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
}
|
||||
let text = format!("Paragraph {i} has words enough to run over several lines of a narrow column, and then some more words. ");
|
||||
s.run("text.insert", &json!({"text": text.repeat(3)})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The page's columns (left, right) from its section's geometry.
|
||||
fn page_columns(s: &mut Session, page: usize) -> Vec<(f32, f32)> {
|
||||
let layout = s.layout();
|
||||
let pg = &layout.pages[page];
|
||||
let boxes = s.doc.sections()[pg.section].1.column_boxes();
|
||||
boxes.iter().map(|(x, w)| (pg.body.x + x, pg.body.x + x + w)).collect()
|
||||
}
|
||||
|
||||
/// #884 review: mirrored indents shift some paragraphs on even pages; that shift isn't taken
|
||||
/// for a column of its own, so the drop anchors to the paragraph under it, mirrored or not.
|
||||
#[test]
|
||||
fn mirrored_indents_make_no_extra_column() {
|
||||
let mut s = two_column_doc(40);
|
||||
for i in (1..40).step_by(2) {
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||||
p.props.mirror_indents = Some(true);
|
||||
p.props.indent_right = Some(36.0);
|
||||
p.touch();
|
||||
}
|
||||
s.touch();
|
||||
assert!(s.layout().pages.len() > 1);
|
||||
let cols = page_columns(&mut s, 1);
|
||||
// On page 2, a plain paragraph starting in the first column below a mirrored one.
|
||||
let layout = s.layout();
|
||||
let starts: Vec<(usize, f32, f32)> = layout.pages[1]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { path, l0: 0, x, y, .. } if path.depth() == 0 && *x < cols[1].0 - 0.5 => Some((path.last(), *x, *y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(starts.iter().any(|p| p.1 > cols[0].0 + 1.0), "mirrored paragraphs shift right: {starts:?}");
|
||||
let &(plain, _, top) = starts.iter().skip(1).find(|p| p.0 % 2 == 0).unwrap();
|
||||
let mid = (cols[0].0 + cols[0].1) / 2.0;
|
||||
let got = anchor_paragraph(&s.doc, &layout, 1, (mid, top + 1.0), StoryRef::Body);
|
||||
assert_eq!(got, Some(wordcraft_doc::Path::top(plain)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
|
||||
let f = s.clipboard.as_ref()?;
|
||||
// System clipboard text that isn't our own copy means someone else copied since.
|
||||
match p::str(v, "text") {
|
||||
Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
|
||||
Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None,
|
||||
_ => Some(String::new()),
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
|
||||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||||
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
|
||||
if let Some(f) = &s.clipboard
|
||||
&& text.as_ref().is_none_or(|t| f.plain_text() == *t)
|
||||
&& text.as_ref().is_none_or(|t| f.clipboard_text() == *t)
|
||||
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
|
||||
{
|
||||
return Ok(f.clone());
|
||||
|
||||
@@ -753,7 +753,11 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = wordcraft_doc::count_words(&text);
|
||||
let words = if s.sel.is_collapsed() || super::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = text.chars().filter(|c| *c != '\n').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
|
||||
@@ -385,12 +385,70 @@ fn ins_char(s: &mut Session, c: char) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Exact sample-text formulas; bounded counts keep typed input from allocating without limit.
|
||||
fn sample_text(s: &Session) -> Option<String> {
|
||||
let at = &s.sel.focus;
|
||||
let para = s.doc.para_at(at)?;
|
||||
if !s.sel.is_collapsed() || at.off != para.len() {
|
||||
return None;
|
||||
}
|
||||
let (args, sentences) = if let Some(args) = para.text.strip_prefix("=rand(") {
|
||||
(
|
||||
args,
|
||||
[
|
||||
"A blank page leaves room for a new idea.",
|
||||
"The morning light falls across a notebook on the desk.",
|
||||
"Small changes in spacing can give a story a different rhythm.",
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
para.text.strip_prefix("=lorem(")?,
|
||||
[
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
|
||||
],
|
||||
)
|
||||
};
|
||||
let args = args.strip_suffix(')')?;
|
||||
if args.contains(['(', ')']) {
|
||||
return None;
|
||||
}
|
||||
let count = |arg: &str| arg.parse::<usize>().ok().filter(|n| (1..=200).contains(n));
|
||||
let counts = if args.is_empty() {
|
||||
Some((5, 3))
|
||||
} else if let Some((p, s)) = args.split_once(',') {
|
||||
count(p).zip(count(s))
|
||||
} else {
|
||||
count(args).map(|p| (p, 3))
|
||||
};
|
||||
let (paragraphs, lines) = counts.unwrap_or((5, 3));
|
||||
let mut text = String::new();
|
||||
for _ in 0..paragraphs {
|
||||
for sentence in sentences.iter().cycle().take(lines) {
|
||||
if !text.is_empty() && !text.ends_with('\r') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(sentence);
|
||||
}
|
||||
text.push('\r');
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
// Enter in a one-line plain text control (or a check box or list) does nothing, as in Word.
|
||||
if super::controls::refuses_paragraph(s) {
|
||||
return sel_result(s);
|
||||
}
|
||||
super::controls::prepare_edit(s)?;
|
||||
if let Some(text) = sample_text(s) {
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
|
||||
type_text(s, &text)?;
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
|
||||
if let Some(auto) = auto {
|
||||
|
||||
+29
-1
@@ -51,6 +51,9 @@ pub struct ExportOptions {
|
||||
pub markup: bool,
|
||||
/// With `markup`, show the comments (Track Changes Options › Comments).
|
||||
pub comments: bool,
|
||||
/// An `.xml` copy is a Word 2003 XML Document (WordprocessingML 2003) instead of a Word XML
|
||||
/// Document (Flat OPC): `format: "wordml2003"` on the save commands.
|
||||
pub word2003: bool,
|
||||
}
|
||||
|
||||
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
|
||||
@@ -110,7 +113,7 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Resu
|
||||
doc.ensure_nonempty();
|
||||
return Ok((doc, true));
|
||||
}
|
||||
if ext.is_empty() {
|
||||
if ext.is_empty() || signature_contradicts(&ext, bytes) {
|
||||
return open_by_content(name, &ext, bytes, password);
|
||||
}
|
||||
let mut doc = match ext.as_str() {
|
||||
@@ -163,6 +166,31 @@ pub(crate) fn fit_pictures(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the file hold a different document format than its Word or RTF extension says? An RTF
|
||||
/// file saved as `.doc` (as accounting systems and report generators often save their
|
||||
/// documents), a `.docx` package renamed `.doc` or a Word 97-2003 file renamed `.docx`: Word opens
|
||||
/// each by what it holds, so they go through [`open_by_content`] instead of failing.
|
||||
fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
|
||||
const ZIP: &[u8] = b"PK\x03\x04";
|
||||
const OLE: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
let held = if bytes.starts_with(ZIP) {
|
||||
"zip"
|
||||
} else if bytes.starts_with(OLE) {
|
||||
"ole"
|
||||
} else if bytes.starts_with(b"{\\rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let expected = match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => "zip",
|
||||
"doc" | "dot" => "ole",
|
||||
"rtf" => "rtf",
|
||||
_ => return false,
|
||||
};
|
||||
held != expected
|
||||
}
|
||||
|
||||
/// A file whose extension names no format WordCraft knows (`.m3u`, `.log`, `.ini`, none…),
|
||||
/// opened by what it holds, as Word does: a Word document or RTF file by its signature, any
|
||||
/// other text through the plain-text import with encoding detection (#569). Anything else is
|
||||
|
||||
@@ -26,6 +26,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string()));
|
||||
}
|
||||
match ext {
|
||||
"xml" if opts.word2003 => Some(wordcraft_docx::write_wordml2003(doc).map_err(|e| e.to_string())),
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
|
||||
@@ -462,7 +462,6 @@ mod tests {
|
||||
assert!(run(json!({"rename": {"tab": "Writing", "to": "insert"}})).is_err());
|
||||
assert!(run(json!({"add": {"tab": "Writing", "group": "New Group", "command": "no.such"}})).is_err());
|
||||
assert!(run(json!({"move": {"tab": "Nope", "by": 1}})).is_err());
|
||||
drop(run);
|
||||
assert!(s.ui_requests.is_empty(), "programmatic calls never open dialogs");
|
||||
s.run("tools.customizeRibbon", &json!({"reset": true})).unwrap();
|
||||
assert_eq!(s.ribbon, RibbonLayout::default());
|
||||
|
||||
@@ -209,6 +209,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn samples_are_valid() {
|
||||
assert_eq!(sample_document().word_count(), 361);
|
||||
for d in [sample_document(), letter(), resume(), report()] {
|
||||
assert!(d.word_count() > 5);
|
||||
let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
|
||||
|
||||
@@ -338,6 +338,9 @@ pub struct Session {
|
||||
pub pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
/// Plain text mirror of the clipboard (for the system clipboard).
|
||||
pub clipboard_text: String,
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`] when the copy holds an
|
||||
/// equation (as MathML, #816), for programs that read MathML; empty otherwise.
|
||||
pub clipboard_html: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
@@ -574,6 +577,7 @@ impl Session {
|
||||
clipboard: None,
|
||||
pending_move: None,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
@@ -1005,6 +1009,7 @@ impl Session {
|
||||
// Like the clipboard: what the next paste can make a move of.
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
|
||||
@@ -505,6 +505,36 @@ fn windows_powershell(system_root: Option<std::ffi::OsString>) -> std::path::Pat
|
||||
std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Win32 `CREATE_NO_WINDOW` process creation flag: a console program starts without a console window.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// How Read Aloud starts Windows PowerShell to speak at `rate`: the program, its arguments and
|
||||
/// its process creation flags. `speak` builds its `Command` from exactly this; std can't read
|
||||
/// creation flags back from a `Command`, so the tests check this value instead.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
struct WindowsSpeech {
|
||||
program: std::path::PathBuf,
|
||||
args: [String; 4],
|
||||
creation_flags: u32,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_speech(system_root: Option<std::ffi::OsString>, rate: f32) -> WindowsSpeech {
|
||||
// SAPI rate: -10 (a third of normal speed) … 10 (three times).
|
||||
let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64;
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())"
|
||||
);
|
||||
WindowsSpeech {
|
||||
program: windows_powershell(system_root),
|
||||
args: ["-NoProfile".into(), "-NonInteractive".into(), "-Command".into(), script],
|
||||
// PowerShell is a console program: without this, every sentence flashes a console window
|
||||
// over the document (#1122).
|
||||
creation_flags: CREATE_NO_WINDOW,
|
||||
}
|
||||
}
|
||||
|
||||
/// Speak one sentence with the system's speech program, stopping early when `changed()`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
@@ -521,13 +551,10 @@ fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// SAPI rate: -10 (a third of normal speed) … 10 (three times).
|
||||
let r = ((rate.ln() / 3f32.ln()) * 10.0).round().clamp(-10.0, 10.0) as i64;
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())"
|
||||
);
|
||||
let mut c = Command::new(windows_powershell(std::env::var_os("SystemRoot")));
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
|
||||
let w = windows_speech(std::env::var_os("SystemRoot"), rate);
|
||||
let mut c = Command::new(&w.program);
|
||||
c.args(&w.args);
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, w.creation_flags);
|
||||
tries.push(c);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -592,6 +619,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_starts_without_a_console_window() {
|
||||
// The flags `speak` passes to `creation_flags` (std can't read them back from the
|
||||
// `Command`): Win32's CREATE_NO_WINDOW (0x0800_0000), or every sentence flashes a
|
||||
// console (#1122).
|
||||
let w = windows_speech(Some("D:\\Win".into()), 1.0);
|
||||
assert_eq!(w.creation_flags, 0x0800_0000);
|
||||
assert_eq!(w.program, windows_powershell(Some("D:\\Win".into())));
|
||||
assert_eq!(w.args[..3], ["-NoProfile", "-NonInteractive", "-Command"]);
|
||||
assert!(w.args[3].contains("$s.Rate = 0;"), "{}", w.args[3]);
|
||||
assert!(windows_speech(None, 3.0).args[3].contains("$s.Rate = 10;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_sentences_but_not_abbreviations() {
|
||||
let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one.";
|
||||
|
||||
@@ -813,6 +813,71 @@ fn clipboard_round_trip() {
|
||||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
/// Equations in the paragraphs of a session's body, as linear text.
|
||||
fn equations(s: &Session) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for b in &s.doc.body {
|
||||
if let wordcraft_doc::Block::Para(p) = &**b {
|
||||
for o in &p.objects {
|
||||
if let wordcraft_doc::para::InlineObject::Equation { linear, .. } = o {
|
||||
out.push(linear.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_an_equation_puts_mathml_on_the_clipboard() {
|
||||
// #816: other programs get the equation as MathML in HTML, beside the linear text.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Speed "}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "insert.equation", json!({"linear": "y=2x_s", "edit": false}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
let html = r["html"].as_str().unwrap_or_default().to_string();
|
||||
assert!(html.contains("<math xmlns=\"http://www.w3.org/1998/Math/MathML\">"), "{html}");
|
||||
assert!(html.contains("<msub><mrow><mi>x</mi></mrow><mrow><mi>s</mi></mrow></msub>"), "{html}");
|
||||
assert!(html.contains("Speed"), "{html}");
|
||||
assert_eq!(s.clipboard_html, html);
|
||||
assert!(r["text"].as_str().unwrap_or_default().contains("y=2x_s"));
|
||||
let clip = r["text"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
// Pasting it into another document reads the equation back from the HTML.
|
||||
let mut t = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut t, "edit.paste", json!({"text": clip, "html": html}));
|
||||
assert_eq!(equations(&t), vec!["y=2x_s".to_string()]);
|
||||
assert!(text(&t).starts_with("Speed"), "{}", text(&t));
|
||||
|
||||
// And back into this one, from our own copy.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "edit.paste", json!({"text": clip, "html": html}));
|
||||
assert_eq!(equations(&s), vec!["y=2x_s".to_string(), "y=2x_s".to_string()]);
|
||||
|
||||
// A copy without an equation carries no HTML.
|
||||
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert!(r.get("html").is_none() && s.clipboard_html.is_empty(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "ok J\u{F0FC}"}));
|
||||
s.doc.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(s.clipboard_text, "ok \u{263A}\u{2713}");
|
||||
// Pasting our own copy back (the system clipboard holds the mapped text) keeps the stored
|
||||
// Wingdings characters, not the Unicode ones.
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
let clip = s.clipboard_text.clone();
|
||||
run(&mut s, "edit.paste", json!({"text": clip}));
|
||||
assert_eq!(text(&s), "ok J\u{F0FC}ok J\u{F0FC}");
|
||||
}
|
||||
|
||||
fn bold_at(s: &Session, block: usize, off: usize) -> bool {
|
||||
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
|
||||
@@ -1433,6 +1498,55 @@ fn accept_all_keeps_a_tracked_paragraph_break() {
|
||||
assert_eq!(run(&mut s, "review.changes", json!({})), json!([]));
|
||||
}
|
||||
|
||||
/// Issue #864: five paragraphs, a tracked deletion from the end of the first to the end of the
|
||||
/// third. The two breaks inside the range and the text of the second and third paragraphs are
|
||||
/// deleted; the third paragraph's own break is not.
|
||||
fn tracked_delete_from_paragraph_end() -> Session {
|
||||
let mut s = s();
|
||||
for (i, word) in ["Alpha", "Bravo", "Charlie", "Delta", "Echo"].iter().enumerate() {
|
||||
if i > 0 {
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": word}));
|
||||
}
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
let range = json!({"anchor": {"path": [0], "off": 5, "story": "body"}, "focus": {"path": [2], "off": 7, "story": "body"}});
|
||||
run(&mut s, "select.range", range);
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph() {
|
||||
let mut s = tracked_delete_from_paragraph_end();
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"], "tracked deletions stay in the text");
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Delta", "Echo"]);
|
||||
assert!(s.doc.revisions.is_empty());
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.mark.ins.is_none() && p.mark.del.is_none()));
|
||||
// Rejecting instead restores everything, as it already did.
|
||||
let mut s = tracked_delete_from_paragraph_end();
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["Alpha", "Bravo", "Charlie", "Delta", "Echo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph() {
|
||||
// Same cause as #864 seen from the other side: rejecting the typed text emptied the
|
||||
// paragraph, and its mark picked up the text's insertion, so it was rejected as well.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "One"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Three"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
run(&mut s, "text.insert", json!({"text": "Two"}));
|
||||
assert_eq!(body_texts(&s), ["One", "Two", "Three"]);
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(body_texts(&s), ["One", "", "Three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_one_paragraph_break_and_rejecting_a_typed_paragraph() {
|
||||
// Reject with the caret on the break (Next Change selects it), or at the end of its paragraph.
|
||||
@@ -1535,6 +1649,56 @@ fn set_author_names_tracked_changes_headlessly() {
|
||||
assert!(s.doc.comments.values().all(|c| c.author == "Claude (copyedit)" && c.initials == "C("));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_options_strip_names_and_dates_from_the_saved_docx_only() {
|
||||
// Issue #1062: Remove personal information / date and time apply to the saved file.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "original"}));
|
||||
run(&mut s, "file.setAuthor", json!({"name": "Mira Calloway"}));
|
||||
run(&mut s, "file.properties", json!({"author": "Mira Calloway"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": " added"}));
|
||||
run(&mut s, "select.text", json!({"text": "added"}));
|
||||
run(&mut s, "review.newComment", json!({"text": "Tightened."}));
|
||||
let off = run(&mut s, "file.privacy", json!({}));
|
||||
assert_eq!(off, json!({"removePersonalInfo": false, "removeDateTime": false}));
|
||||
let plain = wordcraft_docx::write(&s.doc).unwrap();
|
||||
let back = wordcraft_docx::read(&plain).unwrap();
|
||||
assert!(back.comments.values().all(|c| c.author == "Mira Calloway" && !c.date.is_empty()));
|
||||
let on = run(&mut s, "file.privacyOptions", json!({"removePersonalInfo": true, "removeDateTime": true}));
|
||||
assert_eq!(on, json!({"removePersonalInfo": true, "removeDateTime": true}));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert!(back.settings.remove_personal_info && back.settings.remove_date_time);
|
||||
assert!(back.comments.values().all(|c| c.author == "Author" && c.date.is_empty()), "{:?}", back.comments);
|
||||
assert!(!back.revisions.is_empty());
|
||||
assert!(back.revisions.iter().all(|r| r.author == "Author" && r.date.is_empty()), "{:?}", back.revisions);
|
||||
assert!(back.core.creator.is_empty() && back.core.last_modified_by.is_empty());
|
||||
// The open document keeps the real names and dates.
|
||||
assert!(s.doc.comments.values().all(|c| c.author == "Mira Calloway" && !c.date.is_empty()));
|
||||
assert!(s.doc.revisions.iter().any(|r| r.author == "Mira Calloway"));
|
||||
assert_eq!(s.doc.core.creator, "Mira Calloway");
|
||||
// Undoable like any document change.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc.settings.remove_personal_info);
|
||||
}
|
||||
|
||||
/// Reading the privacy settings changes nothing: no undo step, no unsaved changes, and it works
|
||||
/// on a protected document. `file.privacyOptions` without a setting is refused, not a silent edit.
|
||||
#[test]
|
||||
fn reading_privacy_options_is_side_effect_free() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Draft"}));
|
||||
s.dirty = false;
|
||||
let undo = s.undo_depth();
|
||||
assert_eq!(run(&mut s, "file.privacy", json!({})), json!({"removePersonalInfo": false, "removeDateTime": false}));
|
||||
assert!(s.run("file.privacyOptions", &json!({})).is_err());
|
||||
assert!(s.run("file.privacyOptions", &json!({"removePersonalInfo": "yes"})).is_err());
|
||||
assert_eq!(s.undo_depth(), undo, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert_eq!(run(&mut s, "file.privacy", json!({}))["removeDateTime"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_author_refuses_blank_names_and_clamps_long_ones() {
|
||||
let mut s = s();
|
||||
@@ -1618,6 +1782,27 @@ fn comments() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0);
|
||||
}
|
||||
|
||||
/// #1085: a new comment with no text, in an empty document or on a word, goes with Review ›
|
||||
/// Delete at the caret (no id) and by id.
|
||||
#[test]
|
||||
fn empty_comments_delete() {
|
||||
for body in ["", "Some text here"] {
|
||||
for by_id in [false, true] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
if !body.is_empty() {
|
||||
run(&mut s, "text.insert", json!({"text": body}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 6}}));
|
||||
}
|
||||
let id = run(&mut s, "review.newComment", json!({}))["id"].clone();
|
||||
assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], "");
|
||||
run(&mut s, "review.deleteComment", if by_id { json!({"id": id}) } else { json!({}) });
|
||||
assert!(s.doc.comments.is_empty(), "{body:?} by id {by_id}");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0, "{body:?}: anchors gone");
|
||||
assert_eq!(text(&s), body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_and_fields() {
|
||||
let mut s = Session::new(crate::sample::report());
|
||||
@@ -1941,6 +2126,25 @@ fn word_count_includes_text_boxes_by_default() {
|
||||
assert_eq!(s.word_count(), 2);
|
||||
}
|
||||
|
||||
#[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 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))];
|
||||
let mut s = Session::new(d);
|
||||
|
||||
let v = run(&mut s, "review.wordCount", json!({}));
|
||||
// Generated label "1." + "Apple" = 2 words.
|
||||
assert_eq!(v["words"].as_u64(), Some(2));
|
||||
assert_eq!(s.word_count(), 2);
|
||||
// Generated list label "1." is formatting metadata and not run characters,
|
||||
// so Word excludes it from character counts: "Apple" has 5 characters.
|
||||
assert_eq!(v["characters"].as_u64(), Some(5));
|
||||
assert_eq!(v["charactersWithSpaces"].as_u64(), Some(5));
|
||||
}
|
||||
|
||||
/// The laid-out area of the only text box.
|
||||
fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
let l = s.layout();
|
||||
@@ -2455,6 +2659,68 @@ fn styles_apply_multi_paragraph_selection() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #865: `styles.modify` takes a colour the way `format.color` does (`RRGGBB`, `#RRGGBB`, `auto`),
|
||||
/// still reads the serialized forms, and rejects a bad colour without touching the style.
|
||||
#[test]
|
||||
fn modify_style_takes_hex_colours_like_format_color() {
|
||||
use wordcraft_doc::{CharProps, Rgb, TextColor};
|
||||
let mut s = s();
|
||||
let color = |s: &Session| s.doc.styles.get("Heading1").and_then(|st| st.chr.color);
|
||||
let modify = |s: &mut Session, c: serde_json::Value| s.run("styles.modify", &json!({"style": "Heading1", "chr": {"color": c}}));
|
||||
|
||||
modify(&mut s, json!("1B1611")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
|
||||
modify(&mut s, json!("auto")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Auto));
|
||||
modify(&mut s, json!("#1B1611")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))));
|
||||
modify(&mut s, json!("Auto")).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Auto));
|
||||
modify(&mut s, json!({"Rgb": [192, 0, 0]})).unwrap();
|
||||
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
|
||||
assert_eq!(color(&s), Some(red));
|
||||
assert_eq!(s.doc.styles.resolve_char(Some("Heading1"), &CharProps::default()).color, red);
|
||||
|
||||
for bad in [json!("1B16"), json!("not a colour"), json!({"Rgb": "1B16"}), json!(7)] {
|
||||
let r = modify(&mut s, bad.clone());
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}");
|
||||
assert_eq!(color(&s), Some(red), "{bad} leaves the style alone");
|
||||
}
|
||||
modify(&mut s, json!({"Rgb": "1B1611"})).unwrap();
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}");
|
||||
}
|
||||
|
||||
/// #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]
|
||||
fn props_colours_take_hex_strings() {
|
||||
use wordcraft_doc::{Pos, Rgb};
|
||||
let ink = Rgb(0x1B, 0x16, 0x11);
|
||||
let mut s = s();
|
||||
run(
|
||||
&mut s,
|
||||
"styles.modify",
|
||||
json!({"style": "Heading1",
|
||||
"chr": {"underlineColor": "1B1611", "shading": "#1b1611", "border": {"style": "single", "width": 1, "color": "1B1611"}},
|
||||
"para": {"shading": "1B1611", "borders": {"bottom": {"style": "single", "width": 1, "color": "#1B1611"}}}}),
|
||||
);
|
||||
let st = s.doc.styles.get("Heading1").unwrap();
|
||||
assert_eq!((st.chr.underline_color, st.chr.shading, st.chr.border.and_then(|b| b.color)), (Some(ink), Some(ink), Some(ink)));
|
||||
assert_eq!((st.para.shading, st.para.borders.and_then(|b| b.bottom).and_then(|b| b.color)), (Some(ink), Some(ink)));
|
||||
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "para.set", json!({"props": {"shading": "C00000"}}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(0xC0, 0, 0)));
|
||||
run(&mut s, "para.set", json!({"props": {"shading": [1, 2, 3]}}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(1, 2, 3)), "[r, g, b] still reads");
|
||||
|
||||
for bad in [json!("C0000"), json!("auto"), json!([1, 2]), json!([1, 2, 300]), json!(7)] {
|
||||
let r = s.run("para.set", &json!({"props": {"shading": bad}}));
|
||||
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{bad}: {r:?}");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.shading, Some(Rgb(1, 2, 3)), "{bad} changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||||
@@ -3412,6 +3678,33 @@ fn word_xml_saves_and_opens_through_io() {
|
||||
assert!(err.contains("not a Word XML document"), "{err}");
|
||||
}
|
||||
|
||||
/// #1081: `format: "wordml2003"` saves an `.xml` file as a Word 2003 XML Document, a plain save
|
||||
/// over such a file keeps that format, and `format: "flatopc"` converts it back.
|
||||
#[test]
|
||||
fn word_2003_xml_saves_through_the_save_commands() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Kept for old readers"}));
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-2003-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("old.xml").to_string_lossy().to_string();
|
||||
let kind = |p: &str| wordcraft_docx::sniff_xml(&std::fs::read(p).unwrap());
|
||||
run(&mut s, "file.saveAs", json!({"path": path, "format": "wordml2003"}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
|
||||
assert!(crate::io::open_path(std::path::Path::new(&path)).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Kept for old readers"));
|
||||
run(&mut s, "file.save", json!({}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::WordMl2003);
|
||||
run(&mut s, "file.save", json!({"path": path, "format": "flatopc"}));
|
||||
assert_eq!(kind(&path), wordcraft_docx::XmlKind::FlatOpc);
|
||||
for bad in [
|
||||
json!({"path": dir.join("a.docx").to_string_lossy(), "format": "wordml2003"}),
|
||||
json!({"path": path, "format": "bogus"}),
|
||||
json!({"path": path, "format": 7}),
|
||||
] {
|
||||
assert!(s.run("file.save", &bad).is_err(), "{bad}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
@@ -3716,6 +4009,31 @@ fn colour_commands_take_any_custom_hex() {
|
||||
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
|
||||
}
|
||||
|
||||
/// A document whose content is another format than its extension says opens by what it holds,
|
||||
/// as in Word: RTF saved as `.doc` (accounting systems and report generators save their documents
|
||||
/// that way), a `.docx` package renamed `.doc`, and RTF renamed `.docx`.
|
||||
#[test]
|
||||
fn documents_open_by_content_when_the_extension_is_wrong() {
|
||||
let text = |doc: &wordcraft_doc::Document| doc.plain_text(StoryRef::Body);
|
||||
let rtf = b"{\\rtf1\\ansi\\ansicpg1251\\deff0{\\fonttbl{\\f0 Times New Roman;}}\\f0 \\'d1\\'f7\\'e5\\'f2 \\'b9 12\\par Invoice line\\par}";
|
||||
for name in ["Счет 12.doc", "report.DOC", "letter.dot", "renamed.docx"] {
|
||||
let doc = crate::io::open_bytes(name, rtf).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let t = text(&doc);
|
||||
assert!(t.contains("Счет № 12") && t.contains("Invoice line"), "{name}: {t:?}");
|
||||
}
|
||||
// A Word package renamed `.doc` or `.rtf`.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Packaged text"}));
|
||||
let docx = crate::io::save_bytes("a.docx", &s.doc).unwrap();
|
||||
for name in ["package.doc", "package.rtf"] {
|
||||
let doc = crate::io::open_bytes(name, &docx).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert!(text(&doc).contains("Packaged text"), "{name}");
|
||||
}
|
||||
// Matching content still goes the usual way, and garbage stays an error.
|
||||
assert!(text(&crate::io::open_bytes("plain.rtf", rtf).unwrap()).contains("Invoice line"));
|
||||
assert!(crate::io::open_bytes("broken.doc", b"\x00\x01not a document").is_err());
|
||||
}
|
||||
|
||||
/// #569: a playlist (.m3u / .m3u8) — or any file with an extension WordCraft doesn't know —
|
||||
/// opens as plain text with its encoding detected, like Word's text import; a binary file is a
|
||||
/// clean error, never garbage or a panic.
|
||||
@@ -4129,6 +4447,48 @@ fn portuguese_proofing_through_the_review_commands() {
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
fn italian_test_dictionary() {
|
||||
let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
|
||||
let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
|
||||
wordcraft_proof::it::install_dictionary(aff, dic).unwrap();
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Italian end to end: the language makes
|
||||
/// the document's words Italian, F7 and the issue list use the Italian dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
#[test]
|
||||
fn italian_proofing_through_the_review_commands() {
|
||||
italian_test_dictionary();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Casa caffe, e roma roma disse."}));
|
||||
// English by default: the Italian words are English misspellings.
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().len() >= 3);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "it_IT", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("it-IT"), "the tag is normalized");
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "it-IT");
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
let has = |text: &str, kind: &str, sugg: &str| {
|
||||
arr.iter()
|
||||
.any(|i| i["text"] == text && i["kind"] == kind && i["lang"] == "it" && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||||
};
|
||||
assert!(has("caffe", "spelling", "caffè"), "{issues}");
|
||||
assert!(has("roma roma", "grammar", "roma"), "{issues}");
|
||||
assert_eq!(arr.len(), 2, "{issues}");
|
||||
assert!(arr.iter().any(|i| i["message"] == "Parola ripetuta: \"roma\""), "{issues}");
|
||||
// F7 from the start selects the first issue; Change applies a suggestion.
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let first = run(&mut s, "review.spelling", json!({}));
|
||||
assert_eq!(first["text"], "caffe");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "caffè"}));
|
||||
assert!(text(&s).starts_with("Casa caffè,"), "{}", text(&s));
|
||||
// Add to Dictionary works for Italian words too.
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "caffe"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_proofing_through_the_review_commands() {
|
||||
polish_test_dictionary();
|
||||
@@ -4817,3 +5177,76 @@ fn find_replace_and_go_to_open_like_word() {
|
||||
assert_eq!(page(&mut s), 1, "before the start: the first page");
|
||||
assert!(s.run("edit.goto", &json!({"pageBy": i64::MIN})).is_ok(), "hostile numbers never panic");
|
||||
}
|
||||
|
||||
/// #802: Insert › Picture takes Windows Metafiles (and EMF) at their own frame size, stored under
|
||||
/// their own extension so a save writes them as WMF/EMF parts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn insert_picture_takes_a_windows_metafile() {
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
// One WMF record: size in words, function, parameters.
|
||||
let rec = |func: u16, params: &[i16]| {
|
||||
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
|
||||
v.extend(func.to_le_bytes());
|
||||
params.iter().for_each(|p| v.extend(p.to_le_bytes()));
|
||||
v
|
||||
};
|
||||
// A placeable WMF, 1440 × 720 units at 1440 per inch (72 × 36 points), drawing a rectangle.
|
||||
let body = [rec(0x041B, &[720, 1440, 0, 0]), rec(0, &[])].concat();
|
||||
let mut wmf = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
for v in [0i16, 0, 0, 1440, 720] {
|
||||
wmf.extend(v.to_le_bytes());
|
||||
}
|
||||
wmf.extend(1440u16.to_le_bytes());
|
||||
wmf.extend([0u8; 6]);
|
||||
let words = ((wmf.len() + 18 + body.len()) / 2) as u32;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
wmf.extend(v.to_le_bytes());
|
||||
}
|
||||
wmf.extend(words.to_le_bytes());
|
||||
wmf.extend(0u16.to_le_bytes());
|
||||
wmf.extend(5u32.to_le_bytes());
|
||||
wmf.extend(0u16.to_le_bytes());
|
||||
wmf.extend(body);
|
||||
let dir = scratch_dir("insert-wmf");
|
||||
let path = dir.join("logo.wmf");
|
||||
std::fs::write(&path, &wmf).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = run(&mut s, "insert.picture", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(72.0), Some(36.0)), "{r}");
|
||||
let key = r["media"].as_str().unwrap().to_string();
|
||||
assert!(key.ends_with(".wmf"), "{key}");
|
||||
assert_eq!(s.doc.media.get(&key).map(|b| b.len()), Some(wmf.len()));
|
||||
let mut placed = false;
|
||||
s.doc.objects_in_reading_order(&mut |o| placed |= matches!(o, InlineObject::Image { media, .. } if *media == key));
|
||||
assert!(placed, "the picture is in the text");
|
||||
|
||||
// Neither a picture nor a metafile: the same error as before, naming the formats.
|
||||
std::fs::write(&path, b"not a picture").unwrap();
|
||||
let err = s.run("insert.picture", &json!({"path": path.to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("WMF, EMF"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_page_images_draw_headers_at_full_strength() {
|
||||
// file.exportPng (and `wordcraft-cli render`) drew headers and footers dimmed, as the screen
|
||||
// does while the body is edited; Word's exported pages and WordCraft's PDF don't.
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Body"}));
|
||||
let mut hp = wordcraft_doc::Paragraph::with_text("HEADER", wordcraft_doc::props::CharProps { size: Some(28.0), ..Default::default() });
|
||||
hp.props.align = Some(wordcraft_doc::props::Align::Left);
|
||||
let id = s.doc.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
|
||||
s.doc.last_section.headers.default = Some(id);
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-png-header-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("p.png");
|
||||
s.run("file.exportPng", &json!({"path": path.to_string_lossy(), "scale": 1.0})).unwrap();
|
||||
let img = image::open(&path).unwrap().to_luma8();
|
||||
// The darkest pixel in the top tenth of the page (the header) is black, not grey.
|
||||
let top = img.height() / 10;
|
||||
let darkest = (0..top).flat_map(|y| (0..img.width()).map(move |x| (x, y))).map(|(x, y)| img.get_pixel(x, y)[0]).min().unwrap();
|
||||
assert!(darkest < 60, "header drawn dimmed: darkest pixel {darkest}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,84 @@ fn typed(keys: &str) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_text_formulas_expand_on_enter() {
|
||||
for name in ["rand", "lorem"] {
|
||||
for (args, paragraphs, sentences) in [("", 5, 3), ("2", 2, 3), ("2,4", 2, 4), ("1,1", 1, 1), ("200,1", 200, 1), ("1,200", 1, 200)] {
|
||||
let mut s = typed(&format!("={name}({args})"));
|
||||
let depth = s.undo_depth();
|
||||
typ(&mut s, "\n");
|
||||
let generated: Vec<_> = paras(&s).into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(generated.len(), paragraphs + 1, "{name}({args})");
|
||||
for text in generated.iter().take(paragraphs) {
|
||||
assert_eq!(text.matches('.').count(), sentences);
|
||||
assert!(text.starts_with(if name == "lorem" { "Lorem ipsum" } else { "A blank page" }), "{name}({args}): {text:?}");
|
||||
}
|
||||
assert_eq!(generated.last().unwrap(), "");
|
||||
assert_eq!(s.sel.focus, Pos::body(paragraphs, 0));
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let expanded = s.doc.clone();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(texts(&s), [format!("={name}({args})")]);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc, expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_text_invalid_parameters_use_defaults() {
|
||||
for name in ["rand", "lorem"] {
|
||||
for args in ["0", "-1", "201", "abc", "1.5", "99999999999999999999999999999", "2,0", "0,4", "2,201", "2,no", "2,", ",4", "1,2,3"] {
|
||||
let s = typed(&format!("={name}({args})\n"));
|
||||
let texts = texts(&s);
|
||||
assert_eq!(texts.len(), 6, "{name}({args})");
|
||||
assert!(texts.iter().take(5).all(|text| text.matches('.').count() == 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_text_requires_an_exact_paragraph_and_caret_at_end() {
|
||||
for formula in [" =rand()", "=rand() ", "prefix =rand()", "=lorem() suffix", "=other()", "=rand(", "=rand(())"] {
|
||||
assert_eq!(texts(&typed(&format!("{formula}\n"))), [formula, ""]);
|
||||
}
|
||||
let mut s = typed("=rand()");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(texts(&s), ["", "=rand()"]);
|
||||
let mut s = typed("=rand()suffix");
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 7), focus: Pos::body(0, 13) };
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(texts(&s), ["=rand()", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_text_preserves_typing_and_paragraph_formatting() {
|
||||
let mut s = s();
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
typ(&mut s, "=rand(2,1)\n");
|
||||
for p in s.doc.body.iter().filter_map(|b| b.as_para()).take(2) {
|
||||
assert_eq!(p.props.align, Some(wordcraft_doc::props::Align::Center));
|
||||
assert_eq!(p.props_at(0).bold, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_text_replacement_tracks_changes() {
|
||||
let mut s = typed("=lorem(2,1)");
|
||||
run(&mut s, "review.trackChanges", json!({}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).any(|r| r.props.ins.is_some()));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["=lorem(2,1)"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(texts(&s).len(), 3);
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains("=lorem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_twice_after_a_list_item_ends_the_list() {
|
||||
let s = typed("* item\n\ntext");
|
||||
|
||||
@@ -168,6 +168,17 @@ impl FontFace {
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database
|
||||
/// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache.
|
||||
pub fn glyph_outline(&self, gid: u32) -> BezPath {
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = self.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
|
||||
}
|
||||
pen.0
|
||||
}
|
||||
/// Is `family` this face's typographic or legacy family name?
|
||||
pub fn named(&self, family: &str) -> bool {
|
||||
self.family.eq_ignore_ascii_case(family) || self.legacy_family.eq_ignore_ascii_case(family)
|
||||
@@ -1197,6 +1208,71 @@ impl FontDb {
|
||||
.unwrap_or_else(last_resort_face)
|
||||
}
|
||||
|
||||
/// The face a font list entry draws `family`'s name in: its Regular face (or the closest
|
||||
/// style), without loading the family (#848). A loaded family's face is shared; an installed
|
||||
/// one is read into a face of its own that the database doesn't keep, so its data is freed when
|
||||
/// the caller drops it. Browsing the font list (hundreds of families) then costs no lasting
|
||||
/// memory; only fonts actually used load for good. `None` when no font has that name, or its
|
||||
/// file is too large to read just for a preview.
|
||||
pub fn preview_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.find(family, "Regular") {
|
||||
return Some(f);
|
||||
}
|
||||
let english = self.english_family(family).filter(|en| !en.eq_ignore_ascii_case(family));
|
||||
if let Some(f) = english.as_deref().and_then(|en| self.find(en, "Regular")) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(f) = self.unshared_face(family).or_else(|| english.as_deref().and_then(|en| self.unshared_face(en))) {
|
||||
return Some(f);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The installed `family`'s face closest to Regular, read from its file into a face the
|
||||
/// database doesn't keep (see [`FontDb::preview_face`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unshared_face(&self, family: &str) -> Option<Arc<FontFace>> {
|
||||
let (path, fam, style) = {
|
||||
let cat = self.read_catalog();
|
||||
let mut cands: Vec<&CatalogEntry> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).collect();
|
||||
if cands.is_empty() {
|
||||
cands = cat.iter().filter(|c| c.legacy.eq_ignore_ascii_case(family)).collect();
|
||||
}
|
||||
let score = |c: &CatalogEntry| {
|
||||
if matches!(norm(&c.style).as_str(), "regular" | "normal" | "book" | "roman") {
|
||||
-1.0
|
||||
} else {
|
||||
(style_weight(&c.style) - 400.0).abs() + if style_italic(&c.style) { 1000.0 } else { 0.0 }
|
||||
}
|
||||
};
|
||||
let best = cands.into_iter().min_by(|a, b| score(a).total_cmp(&score(b)))?;
|
||||
(best.path.clone(), best.family.clone(), best.style.clone())
|
||||
};
|
||||
let data = Arc::new(read_fallback_file(&path)?);
|
||||
let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
|
||||
let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?;
|
||||
face.local_names = found.local;
|
||||
Some(Arc::new(face))
|
||||
}
|
||||
|
||||
/// Bytes of font data held by loaded faces (each file once; compiled-in fonts not counted).
|
||||
/// It grows as families load for use; font list previews leave it as is (#848).
|
||||
pub fn loaded_font_bytes(&self) -> usize {
|
||||
let mut seen: Vec<*const Vec<u8>> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for f in self.read_faces().iter() {
|
||||
if let FontBytes::Owned(d) = &f.bytes {
|
||||
let p = Arc::as_ptr(d);
|
||||
if !seen.contains(&p) {
|
||||
seen.push(p);
|
||||
total = total.saturating_add(d.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Is `family` available (loaded, or installed on the system)? English and localized family
|
||||
/// names both count.
|
||||
pub fn has_family(&self, family: &str) -> bool {
|
||||
@@ -1211,7 +1287,7 @@ impl FontDb {
|
||||
}
|
||||
|
||||
/// Is a face named `family` (its typographic or legacy family) loaded?
|
||||
fn is_loaded(&self, family: &str) -> bool {
|
||||
pub fn is_loaded(&self, family: &str) -> bool {
|
||||
self.read_faces().iter().any(|f| f.named(family))
|
||||
}
|
||||
|
||||
@@ -1569,13 +1645,7 @@ impl FontDb {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let p = Arc::new(face.glyph_outline(gid));
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
|
||||
@@ -8,9 +8,15 @@ const FAMILY: &str = "Sysfont Sans3";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name).
|
||||
fn renamed(file: &str) -> Vec<u8> {
|
||||
renamed_to(file, FAMILY)
|
||||
}
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (13 characters, as long as the original name).
|
||||
fn renamed_to(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
@@ -405,3 +411,48 @@ fn east_asian_fonts_are_listed_for_the_asian_text_font_box() {
|
||||
assert!(db.face("Songti Test", "Regular").is_east_asian());
|
||||
assert_eq!(listed(&db), 1);
|
||||
}
|
||||
|
||||
/// #848: browsing the font list drew each family's name in its own face, loading every family
|
||||
/// shown for good (tens of megabytes per trip through the Font box). Preview faces are read for
|
||||
/// the preview alone: however many families are previewed, the database keeps no font data, and
|
||||
/// each preview face is freed when dropped.
|
||||
#[test]
|
||||
fn font_list_previews_load_nothing_for_good() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-sysfonts-{}-previews", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let families: Vec<String> = (0..40).map(|i| format!("Preview Fam{i:02}")).collect();
|
||||
for (i, f) in families.iter().enumerate() {
|
||||
std::fs::write(dir.join(format!("p{i}-Regular.ttf")), renamed_to("SourceSans3-Regular.ttf", f)).unwrap();
|
||||
std::fs::write(dir.join(format!("p{i}-Bold.ttf")), renamed_to("SourceSans3-Bold.ttf", f)).unwrap();
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let (faces, bytes) = (db.read_faces().len(), db.loaded_font_bytes());
|
||||
// Every family previewed many times over, as reopening the list does.
|
||||
for _ in 0..5 {
|
||||
for f in &families {
|
||||
let face = db.preview_face(f).expect("installed family previews");
|
||||
assert_eq!(face.family, *f);
|
||||
assert_eq!(face.style, "Regular", "the preview shows the Regular face");
|
||||
assert!(face.covers('P'));
|
||||
assert_eq!(Arc::strong_count(&face), 1, "nothing else keeps the preview face (or its data)");
|
||||
}
|
||||
}
|
||||
assert_eq!(db.read_faces().len(), faces, "previews load no faces");
|
||||
assert_eq!(db.loaded_font_bytes(), bytes, "previews keep no font data");
|
||||
assert!(db.preview_face("No Such Family").is_none());
|
||||
|
||||
// Using a font loads it once; previewing it again shares the loaded face.
|
||||
assert_eq!(db.face(&families[0], "Regular").family, families[0]);
|
||||
let used = db.loaded_font_bytes();
|
||||
assert!(used > bytes, "a used family's data is counted");
|
||||
let shared = db.preview_face(&families[0]).unwrap();
|
||||
assert!(Arc::strong_count(&shared) > 1, "a loaded family's preview is the shared face");
|
||||
for _ in 0..3 {
|
||||
for f in &families[1..] {
|
||||
drop(db.preview_face(f));
|
||||
}
|
||||
}
|
||||
assert_eq!(db.loaded_font_bytes(), used, "memory stays bounded across previews");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+35
-41
@@ -30,7 +30,9 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"];
|
||||
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"];
|
||||
// Word's symbol fonts (Symbol, Wingdings…) are proprietary: their characters are drawn as their
|
||||
// Unicode equivalents (`wordcraft_doc::symbol_fonts`) from these faces or any face that has them.
|
||||
const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Noto Sans Symbols 2", "Source Sans 3"];
|
||||
// Persian / Arabic document fonts. Naskh (book) faces such as B Nazanin, B Lotus, B Zar and
|
||||
// Traditional Arabic, then the sans faces (B Yekan, B Titr, IRANSans) people use for
|
||||
// headings and screens. Open fonts with full Persian coverage first.
|
||||
@@ -66,7 +68,7 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
"arial" | "helvetica" | "arial nova" => ARIAL,
|
||||
"times new roman" | "times" => TIMES,
|
||||
"courier new" | "courier" | "consolas" | "lucida console" | "cascadia code" | "cascadia mono" => MONO,
|
||||
"symbol" | "wingdings" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"symbol" | "wingdings" | "wingdings 2" | "wingdings 3" | "segoe ui symbol" | "webdings" => SYMBOL,
|
||||
"cambria"
|
||||
| "cambria math"
|
||||
| "georgia"
|
||||
@@ -85,6 +87,20 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The font a Windows 3.1-era charset name stands for: `Times New Roman Cyr`, `Arial CE`,
|
||||
/// `Courier New Baltic`, `Arial (Hebrew)`… are aliases Windows keeps (its FontSubstitutes) for
|
||||
/// the font itself, and old Russian and Central European documents name them. Other names are
|
||||
/// returned unchanged.
|
||||
pub fn charset_base_family(family: &str) -> &str {
|
||||
const SUFFIXES: &[&str] = &[" cyr", " ce", " greek", " tur", " baltic", " (arabic)", " (hebrew)", " (vietnamese)"];
|
||||
let lower = family.trim_end().to_ascii_lowercase();
|
||||
SUFFIXES
|
||||
.iter()
|
||||
.find(|s| lower.ends_with(*s) && lower.len() > s.len())
|
||||
.and_then(|s| family.trim_end().get(..lower.len() - s.len()))
|
||||
.map_or(family, str::trim_end)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -192,10 +208,14 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let base = charset_base_family(family);
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
let s = substitutes(family).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
let s = substitutes(base).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
(s.to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
@@ -207,34 +227,6 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
r
|
||||
}
|
||||
|
||||
/// The standard Unicode character for a Symbol or Wingdings character stored the way Word stores
|
||||
/// it, as the private-use code U+F000 + the font's own code (U+F0B7 is the Symbol bullet). Used
|
||||
/// when that font isn't installed: a substitute draws something else, or nothing, at the
|
||||
/// private-use code. `None` for other fonts and for codes not in the table.
|
||||
pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
|
||||
let code = u32::from(c).checked_sub(0xF000)?;
|
||||
let u = match (family.to_ascii_lowercase().as_str(), code) {
|
||||
// Adobe Symbol encoding.
|
||||
("symbol", 0xA7) => '\u{2663}',
|
||||
("symbol", 0xA8) => '\u{2666}',
|
||||
("symbol", 0xA9) => '\u{2665}',
|
||||
("symbol", 0xAA) => '\u{2660}',
|
||||
("symbol", 0xAE) => '\u{2192}',
|
||||
("symbol", 0xB7) => '\u{2022}',
|
||||
("symbol", 0xD7) => '\u{22C5}',
|
||||
("symbol", 0xE0) => '\u{25CA}',
|
||||
// Wingdings characters common as list bullets.
|
||||
("wingdings", 0x6C) => '\u{25CF}',
|
||||
("wingdings", 0x6E) => '\u{25A0}',
|
||||
("wingdings", 0x76) => '\u{2756}',
|
||||
("wingdings", 0xA7) => '\u{25AA}',
|
||||
("wingdings", 0xD8) => '\u{27A2}',
|
||||
("wingdings", 0xFC) => '\u{2714}',
|
||||
_ => return None,
|
||||
};
|
||||
Some(u)
|
||||
}
|
||||
|
||||
/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent.
|
||||
fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
|
||||
(f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs())
|
||||
@@ -304,19 +296,21 @@ mod tests {
|
||||
}
|
||||
// Other serif families are unchanged.
|
||||
assert_eq!(substitutes("Georgia").first(), Some(&"Caladea"));
|
||||
// Charset aliases are the font itself, so they substitute like it.
|
||||
assert_eq!(charset_base_family("Times New Roman Cyr"), "Times New Roman");
|
||||
assert_eq!(charset_base_family("Arial CYR"), "Arial");
|
||||
assert_eq!(charset_base_family("Courier New CE"), "Courier New");
|
||||
assert_eq!(charset_base_family("Arial (Hebrew)"), "Arial");
|
||||
assert_eq!(charset_base_family("Cyr"), "Cyr");
|
||||
assert_eq!(charset_base_family("Calibri"), "Calibri");
|
||||
assert_eq!(substitutes(charset_base_family("Times New Roman Cyr")), substitutes("Times New Roman"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_chars_map_to_unicode() {
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("symbol", '\u{F0B7}'), Some('\u{2022}'));
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0A7}'), Some('\u{25AA}'));
|
||||
assert_eq!(symbol_font_char("Wingdings", '\u{F0D8}'), Some('\u{27A2}'));
|
||||
// Same code, other font; unknown codes; ordinary characters.
|
||||
assert_eq!(symbol_font_char("Arial", '\u{F0B7}'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{F041}'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", 'a'), None);
|
||||
assert_eq!(symbol_font_char("Symbol", '\u{10FFFF}'), None);
|
||||
fn symbol_fonts_substitute_symbol_faces() {
|
||||
for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] {
|
||||
assert!(substitutes(f).contains(&"Noto Sans Symbols 2"), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+101
-2
@@ -1060,6 +1060,7 @@ impl<'r> Builder<'r> {
|
||||
covered: false,
|
||||
header: name == "th",
|
||||
shading,
|
||||
borders: None,
|
||||
});
|
||||
fmt.background = None;
|
||||
self.containers.push(Vec::new());
|
||||
@@ -1124,6 +1125,15 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
Tok::Text(t) => b.text(&t),
|
||||
Tok::Start { name, attrs, self_close } => {
|
||||
b.close_head_for_start(&name);
|
||||
if crate::mathml::local(&name) == "math" && !self_close {
|
||||
let el = read_math(&mut lx, name, attrs);
|
||||
if !b.in_head()
|
||||
&& let Some((linear, display)) = crate::mathml::read(&el)
|
||||
{
|
||||
b.start_para().inlines.push(Inline::Equation { linear, display });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
|
||||
lx.skip_past(&format!("</{name}"));
|
||||
lx.skip_past(">");
|
||||
@@ -1137,6 +1147,86 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
b.finish()
|
||||
}
|
||||
|
||||
/// A `<math>` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size
|
||||
/// are bounded; an unclosed element ends with the input.
|
||||
fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El {
|
||||
use crate::mathml::{El, Item, MAX_NODES, local};
|
||||
let mut stack: Vec<El> = vec![El { name, attrs, children: Vec::new() }];
|
||||
let mut count = 0usize;
|
||||
let close = |stack: &mut Vec<El>| {
|
||||
if stack.len() > 1
|
||||
&& let Some(done) = stack.pop()
|
||||
&& let Some(parent) = stack.last_mut()
|
||||
{
|
||||
parent.children.push(Item::El(done));
|
||||
}
|
||||
};
|
||||
while let Some(t) = lx.next() {
|
||||
count += 1;
|
||||
if count > MAX_NODES * 4 {
|
||||
// Over the cap: drop the rest of this element so its tokens aren't read as HTML text.
|
||||
skip_math(lx, t);
|
||||
break;
|
||||
}
|
||||
match t {
|
||||
Tok::Text(t) => {
|
||||
if let Some(top) = stack.last_mut()
|
||||
&& !t.is_empty()
|
||||
{
|
||||
top.children.push(Item::Text(t));
|
||||
}
|
||||
}
|
||||
Tok::Start { name, attrs, self_close } => {
|
||||
let el = El { name, attrs, children: Vec::new() };
|
||||
if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
top.children.push(Item::El(el));
|
||||
}
|
||||
} else {
|
||||
stack.push(el);
|
||||
}
|
||||
}
|
||||
Tok::End(name) => {
|
||||
let name = local(&name).to_string();
|
||||
// Close up to the matching open element; a stray end tag is ignored.
|
||||
if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) {
|
||||
if k == 0 {
|
||||
break;
|
||||
}
|
||||
while stack.len() > k {
|
||||
close(&mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while stack.len() > 1 {
|
||||
close(&mut stack);
|
||||
}
|
||||
stack.pop().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Skip tokens (starting with `first`) up to and including the `</math>` that closes the element
|
||||
/// being read; nested `<math>` elements are counted. An unclosed element ends with the input.
|
||||
fn skip_math(lx: &mut Lexer, first: Tok) {
|
||||
use crate::mathml::local;
|
||||
let mut depth = 0usize;
|
||||
let mut t = Some(first);
|
||||
while let Some(tok) = t {
|
||||
match tok {
|
||||
Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1),
|
||||
Tok::End(name) if local(&name) == "math" => {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
t = lx.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse HTML into a document.
|
||||
pub fn import(s: &str) -> Document {
|
||||
model::to_doc(&parse(s))
|
||||
@@ -1197,7 +1287,6 @@ fn text_html(t: &str) -> String {
|
||||
/// Inline spans → HTML.
|
||||
pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
let mut o = String::new();
|
||||
let inlines = crate::model::equations_as_text(inlines);
|
||||
for i in inlines.iter() {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
@@ -1268,7 +1357,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", esc(a))),
|
||||
// No picture to show: the browser shows the alt text in its place.
|
||||
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
|
||||
Inline::Equation { .. } => {}
|
||||
// MathML, which browsers, Word and LibreOffice show as an equation (#816).
|
||||
Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)),
|
||||
}
|
||||
}
|
||||
o
|
||||
@@ -1447,6 +1537,15 @@ pub fn export(doc: &Document) -> String {
|
||||
export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en"))
|
||||
}
|
||||
|
||||
/// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system
|
||||
/// clipboard beside its text, equations as MathML (#816).
|
||||
pub fn export_fragment(doc: &Document) -> String {
|
||||
let flow = model::from_doc(doc);
|
||||
let mut out = String::from("<meta charset=\"utf-8\">");
|
||||
blocks_html(&flow.blocks, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn export_flow(flow: &Flow, lang: &str) -> String {
|
||||
let m = &flow.meta;
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -416,6 +416,8 @@ pub fn escape_text(s: &str) -> String {
|
||||
'>' => o.push_str("\\textgreater{}"),
|
||||
'|' => o.push_str("\\textbar{}"),
|
||||
'\u{A0}' => o.push('~'),
|
||||
// An opening bracket here would become an optional item or line-break argument.
|
||||
'[' if !line_has_text => o.push_str("{[}"),
|
||||
// Keep TeX from joining `--`, ``` `` ``` and `''` into dashes and quotes.
|
||||
'-' if prev == '-' => o.push_str("{}-"),
|
||||
'`' => o.push_str("\\textasciigrave{}"),
|
||||
@@ -3029,6 +3031,15 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after.
|
||||
assert!(md.contains("π r^2"), "{md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_brackets_stay_text() {
|
||||
// `\item [x]` and `\\ [1]` would read the bracket as an optional argument.
|
||||
assert_eq!(escape_text("[x] a\n [1] b [c]"), "{[}x] a\\\\\n {[}1] b [c]");
|
||||
let doc = crate::markdown::import("- [x] done");
|
||||
let back = import(export(&doc).as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()), "[x] done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_characters_round_trip() {
|
||||
let text = "50% of $5 & #1_a {b} ~c^ \\d <e> |f| -- ``g'' tab\there";
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod comments;
|
||||
pub mod html;
|
||||
pub mod latex;
|
||||
pub mod markdown;
|
||||
pub mod mathml;
|
||||
pub mod model;
|
||||
pub mod odt;
|
||||
pub mod rtf;
|
||||
|
||||
@@ -1298,7 +1298,13 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
|
||||
let pad = " ".repeat(4 * lv);
|
||||
let cont = " ".repeat(4 * lv + marker.len() + 1);
|
||||
let body = body.replace("\\\n", &format!("\\\n{cont}"));
|
||||
let body = if p.kind == Kind::Code { code_span(&p.text()) } else { body };
|
||||
let body = if p.kind == Kind::Code {
|
||||
code_span(&p.text())
|
||||
} else {
|
||||
let body = escape_line_start(body);
|
||||
// The bullet marker can turn a short run of hyphens into a rule.
|
||||
if thematic_break(&format!("{marker} {body}")) { format!("\\{body}") } else { body }
|
||||
};
|
||||
out.push(format!("{pad}{marker} {body}").trim_end().to_string());
|
||||
prev_list = true;
|
||||
let next_is_list = matches!(blocks.get(i + 1), Some(FBlock::Para(q)) if q.list.is_some());
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
//! Equations as MathML (W3C MathML Core / Presentation MathML): written into HTML export and the
|
||||
//! HTML a copy puts on the system clipboard, so programs that read MathML can get a formatted
|
||||
//! equation instead of its linear text (#816; pasting into Word and LibreOffice not yet verified;
|
||||
//! OMML for Word is #1297); and read back from pasted or opened HTML.
|
||||
//!
|
||||
//! The writer walks the equation tree ([`MNode`]) and wraps it in `<semantics>` with the linear
|
||||
//! format as an `application/x-unicodemath` annotation, which renderers ignore and WordCraft reads
|
||||
//! back exactly. The reader takes that annotation when present, else a TeX annotation, else
|
||||
//! builds the tree from the presentation elements (fractions, scripts, radicals, under/over
|
||||
//! limits, tables, fences); both bound their nesting and size.
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, ColJc, FracKind, LimLoc, MAX_DEPTH, MClass, MNode, MRun, MScr, MSty, ScriptKind, is_integral, is_nary_char, math_alnum, math_class,
|
||||
to_linear,
|
||||
};
|
||||
use wordcraft_doc::math_linear::FUNCTION_NAMES;
|
||||
|
||||
use crate::html::esc;
|
||||
|
||||
/// The MathML namespace.
|
||||
pub const NS: &str = "http://www.w3.org/1998/Math/MathML";
|
||||
/// The annotation encoding carrying the linear format (UnicodeMath).
|
||||
pub const UNICODEMATH: &str = "application/x-unicodemath";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Writer
|
||||
|
||||
/// An equation as a `<math>` element. `linear` (the linear format) goes in an annotation for an
|
||||
/// exact round trip; empty = taken from the tree.
|
||||
pub fn to_mathml(nodes: &[MNode], display: bool, linear: &str) -> String {
|
||||
let mut o = String::new();
|
||||
o.push_str(&format!("<math xmlns=\"{NS}\""));
|
||||
if display {
|
||||
o.push_str(" display=\"block\"");
|
||||
}
|
||||
o.push_str("><semantics><mrow>");
|
||||
arg(nodes, &mut o, 0, false);
|
||||
o.push_str("</mrow>");
|
||||
let linear = if linear.is_empty() { to_linear(nodes) } else { linear.to_string() };
|
||||
if !linear.is_empty() {
|
||||
o.push_str(&format!("<annotation encoding=\"{UNICODEMATH}\">{}</annotation>", esc(&linear)));
|
||||
}
|
||||
o.push_str("</semantics></math>");
|
||||
o
|
||||
}
|
||||
|
||||
/// An equation known by its linear format, as a `<math>` element.
|
||||
pub fn linear_to_mathml(linear: &str, display: bool) -> String {
|
||||
to_mathml(&wordcraft_doc::math::parse_linear(linear), display, linear)
|
||||
}
|
||||
|
||||
/// An argument as a row of elements. `eq_arr`: a row of an equation array, whose `&` marks are
|
||||
/// alignment points, not text.
|
||||
fn arg(a: &[MNode], o: &mut String, depth: usize, eq_arr: bool) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in a {
|
||||
node(n, o, depth + 1, eq_arr);
|
||||
}
|
||||
}
|
||||
|
||||
/// An argument as one element (`<mrow>` around several).
|
||||
fn group(a: &[MNode], o: &mut String, depth: usize) {
|
||||
o.push_str("<mrow>");
|
||||
arg(a, o, depth, false);
|
||||
o.push_str("</mrow>");
|
||||
}
|
||||
|
||||
fn mo(c: char, attrs: &str, o: &mut String) {
|
||||
o.push_str(&format!("<mo{attrs}>{}</mo>", esc(&c.to_string())));
|
||||
}
|
||||
|
||||
fn run(r: &MRun, o: &mut String, eq_arr: bool) {
|
||||
let color = r.color.map(|c| format!(" mathcolor=\"#{}\"", c.hex())).unwrap_or_default();
|
||||
if r.nor {
|
||||
if !r.text.is_empty() {
|
||||
o.push_str(&format!("<mtext{color}>{}</mtext>", esc(&r.text)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bold = matches!(r.sty, Some(MSty::Bold | MSty::BoldItalic));
|
||||
let italic = match r.sty {
|
||||
None => r.scr == MScr::Roman,
|
||||
Some(s) => matches!(s, MSty::Italic | MSty::BoldItalic),
|
||||
};
|
||||
let cs: Vec<char> = r.text.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
|
||||
let mut num = String::new();
|
||||
while let Some(&d) =
|
||||
cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
|
||||
{
|
||||
num.push(d);
|
||||
i += 1;
|
||||
}
|
||||
let num: String = if bold { num.chars().map(|d| math_alnum(d, true, false, MScr::Roman)).collect() } else { num };
|
||||
o.push_str(&format!("<mn{color}>{}</mn>", esc(&num)));
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
if c.is_whitespace() || (eq_arr && c == '&') {
|
||||
continue;
|
||||
}
|
||||
if c.is_alphabetic() {
|
||||
if bold || r.scr != MScr::Roman {
|
||||
let m = math_alnum(c, bold, italic, r.scr);
|
||||
let plain = if m == c && !italic { " mathvariant=\"normal\"" } else { "" };
|
||||
o.push_str(&format!("<mi{plain}{color}>{}</mi>", esc(&m.to_string())));
|
||||
} else if italic {
|
||||
o.push_str(&format!("<mi{color}>{}</mi>", esc(&c.to_string())));
|
||||
} else {
|
||||
o.push_str(&format!("<mi mathvariant=\"normal\"{color}>{}</mi>", esc(&c.to_string())));
|
||||
}
|
||||
} else if math_class(c) == MClass::Ord && !c.is_ascii_punctuation() && c != '\u{2061}' {
|
||||
// ∞, ∂, ∅, ℏ…: identifiers, not operators.
|
||||
o.push_str(&format!("<mi{color}>{}</mi>", esc(&c.to_string())));
|
||||
} else {
|
||||
mo(c, &color, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A function name (`sin`, `lim`): one upright identifier when it's plain text.
|
||||
fn func_name(name: &[MNode], o: &mut String, depth: usize) {
|
||||
match name {
|
||||
[MNode::Run(r)] if !r.nor && r.text.chars().all(char::is_alphabetic) && !r.text.is_empty() => {
|
||||
o.push_str(&format!("<mi>{}</mi>", esc(&r.text)));
|
||||
}
|
||||
_ => group(name, o, depth),
|
||||
}
|
||||
}
|
||||
|
||||
fn node(n: &MNode, o: &mut String, depth: usize, eq_arr: bool) {
|
||||
match n {
|
||||
MNode::Run(r) => run(r, o, eq_arr),
|
||||
MNode::Frac { kind, num, den } => match kind {
|
||||
FracKind::Bar | FracKind::NoBar => {
|
||||
o.push_str(if *kind == FracKind::NoBar { "<mfrac linethickness=\"0\">" } else { "<mfrac>" });
|
||||
group(num, o, depth);
|
||||
group(den, o, depth);
|
||||
o.push_str("</mfrac>");
|
||||
}
|
||||
FracKind::Skewed | FracKind::Linear => {
|
||||
o.push_str("<mrow>");
|
||||
group(num, o, depth);
|
||||
mo(if *kind == FracKind::Skewed { '⁄' } else { '/' }, "", o);
|
||||
group(den, o, depth);
|
||||
o.push_str("</mrow>");
|
||||
}
|
||||
},
|
||||
MNode::Script { kind, base, sub, sup } => match kind {
|
||||
ScriptKind::Sup => {
|
||||
o.push_str("<msup>");
|
||||
group(base, o, depth);
|
||||
group(sup, o, depth);
|
||||
o.push_str("</msup>");
|
||||
}
|
||||
ScriptKind::Sub => {
|
||||
o.push_str("<msub>");
|
||||
group(base, o, depth);
|
||||
group(sub, o, depth);
|
||||
o.push_str("</msub>");
|
||||
}
|
||||
ScriptKind::SubSup => {
|
||||
o.push_str("<msubsup>");
|
||||
group(base, o, depth);
|
||||
group(sub, o, depth);
|
||||
group(sup, o, depth);
|
||||
o.push_str("</msubsup>");
|
||||
}
|
||||
ScriptKind::Pre => {
|
||||
o.push_str("<mmultiscripts>");
|
||||
group(base, o, depth);
|
||||
o.push_str("<mprescripts/>");
|
||||
group(sub, o, depth);
|
||||
group(sup, o, depth);
|
||||
o.push_str("</mmultiscripts>");
|
||||
}
|
||||
},
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
if *deg_hide || deg.is_empty() {
|
||||
o.push_str("<msqrt>");
|
||||
arg(e, o, depth, false);
|
||||
o.push_str("</msqrt>");
|
||||
} else {
|
||||
o.push_str("<mroot>");
|
||||
group(e, o, depth);
|
||||
group(deg, o, depth);
|
||||
o.push_str("</mroot>");
|
||||
}
|
||||
}
|
||||
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
|
||||
let under_over = match lim_loc {
|
||||
Some(l) => *l == LimLoc::UndOvr,
|
||||
None => !is_integral(*chr),
|
||||
};
|
||||
let has_sub = !*sub_hide && !sub.is_empty();
|
||||
let has_sup = !*sup_hide && !sup.is_empty();
|
||||
let tag = match (under_over, has_sub, has_sup) {
|
||||
(_, false, false) => "",
|
||||
(true, true, true) => "munderover",
|
||||
(true, true, false) => "munder",
|
||||
(true, false, true) => "mover",
|
||||
(false, true, true) => "msubsup",
|
||||
(false, true, false) => "msub",
|
||||
(false, false, true) => "msup",
|
||||
};
|
||||
o.push_str("<mrow>");
|
||||
if !tag.is_empty() {
|
||||
o.push_str(&format!("<{tag}>"));
|
||||
}
|
||||
mo(*chr, " largeop=\"true\" movablelimits=\"false\"", o);
|
||||
if has_sub {
|
||||
group(sub, o, depth);
|
||||
}
|
||||
if has_sup {
|
||||
group(sup, o, depth);
|
||||
}
|
||||
if !tag.is_empty() {
|
||||
o.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
group(e, o, depth);
|
||||
o.push_str("</mrow>");
|
||||
}
|
||||
MNode::Delim { beg, end, sep, grow, elems, .. } => {
|
||||
let stretchy = if *grow { "" } else { " stretchy=\"false\"" };
|
||||
o.push_str("<mrow>");
|
||||
if let Some(b) = beg {
|
||||
mo(*b, &format!(" fence=\"true\" form=\"prefix\"{stretchy}"), o);
|
||||
}
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
mo(sep.unwrap_or('|'), &format!(" separator=\"true\"{stretchy}"), o);
|
||||
}
|
||||
group(e, o, depth);
|
||||
}
|
||||
if let Some(c) = end {
|
||||
mo(*c, &format!(" fence=\"true\" form=\"postfix\"{stretchy}"), o);
|
||||
}
|
||||
o.push_str("</mrow>");
|
||||
}
|
||||
MNode::Func { name, e } => {
|
||||
o.push_str("<mrow>");
|
||||
func_name(name, o, depth);
|
||||
mo('\u{2061}', "", o);
|
||||
group(e, o, depth);
|
||||
o.push_str("</mrow>");
|
||||
}
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
let tag = if *upper { "mover" } else { "munder" };
|
||||
o.push_str(&format!("<{tag}>"));
|
||||
func_name(e, o, depth);
|
||||
group(lim, o, depth);
|
||||
o.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
o.push_str("<mover accent=\"true\">");
|
||||
group(e, o, depth);
|
||||
mo(spacing_accent(*chr), "", o);
|
||||
o.push_str("</mover>");
|
||||
}
|
||||
MNode::Bar { top, e } => {
|
||||
if *top {
|
||||
o.push_str("<mover accent=\"true\">");
|
||||
group(e, o, depth);
|
||||
mo('\u{203E}', "", o);
|
||||
o.push_str("</mover>");
|
||||
} else {
|
||||
o.push_str("<munder accentunder=\"true\">");
|
||||
group(e, o, depth);
|
||||
mo('_', "", o);
|
||||
o.push_str("</munder>");
|
||||
}
|
||||
}
|
||||
MNode::BorderBox { hide, strike, e } => {
|
||||
let mut notation: Vec<&str> = Vec::new();
|
||||
for (h, name) in hide.iter().zip(["top", "bottom", "left", "right"]) {
|
||||
if !h {
|
||||
notation.push(name);
|
||||
}
|
||||
}
|
||||
if notation.len() == 4 {
|
||||
notation = vec!["box"];
|
||||
}
|
||||
for (s, name) in strike.iter().zip(["horizontalstrike", "verticalstrike", "updiagonalstrike", "downdiagonalstrike"]) {
|
||||
if *s {
|
||||
notation.push(name);
|
||||
}
|
||||
}
|
||||
o.push_str(&format!("<menclose notation=\"{}\">", notation.join(" ")));
|
||||
arg(e, o, depth, false);
|
||||
o.push_str("</menclose>");
|
||||
}
|
||||
MNode::Boxed { e } => group(e, o, depth),
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
let tag = if *top { "mover" } else { "munder" };
|
||||
o.push_str(&format!("<{tag}>"));
|
||||
group(e, o, depth);
|
||||
mo(*chr, " stretchy=\"true\"", o);
|
||||
o.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
MNode::EqArr { rows } => {
|
||||
// `displaystyle` marks an equation array; a bare one-column table reads as a matrix.
|
||||
o.push_str("<mtable displaystyle=\"true\">");
|
||||
for r in rows {
|
||||
o.push_str("<mtr><mtd>");
|
||||
arg(r, o, depth, true);
|
||||
o.push_str("</mtd></mtr>");
|
||||
}
|
||||
o.push_str("</mtable>");
|
||||
}
|
||||
MNode::Matrix { rows, col_jc } => {
|
||||
o.push_str("<mtable>");
|
||||
for r in rows {
|
||||
o.push_str("<mtr>");
|
||||
for (k, cell) in r.iter().enumerate() {
|
||||
match col_jc.get(k) {
|
||||
Some(ColJc::Left) => o.push_str("<mtd columnalign=\"left\">"),
|
||||
Some(ColJc::Right) => o.push_str("<mtd columnalign=\"right\">"),
|
||||
_ => o.push_str("<mtd>"),
|
||||
}
|
||||
arg(cell, o, depth, false);
|
||||
o.push_str("</mtd>");
|
||||
}
|
||||
o.push_str("</mtr>");
|
||||
}
|
||||
o.push_str("</mtable>");
|
||||
}
|
||||
MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => {
|
||||
let mut pad = String::new();
|
||||
for (z, a) in [(zero_wid, "width"), (zero_asc, "height"), (zero_desc, "depth")] {
|
||||
if *z {
|
||||
pad.push_str(&format!(" {a}=\"0\""));
|
||||
}
|
||||
}
|
||||
if !pad.is_empty() {
|
||||
o.push_str(&format!("<mpadded{pad}>"));
|
||||
}
|
||||
o.push_str(if *show { "<mrow>" } else { "<mphantom>" });
|
||||
arg(e, o, depth, false);
|
||||
o.push_str(if *show { "</mrow>" } else { "</mphantom>" });
|
||||
if !pad.is_empty() {
|
||||
o.push_str("</mpadded>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Combining accents (how equations keep them) and the spacing characters MathML puts over the
|
||||
/// base, both ways.
|
||||
const ACCENTS: &[(char, char)] = &[
|
||||
('\u{0302}', '^'),
|
||||
('\u{0303}', '~'),
|
||||
('\u{0304}', '¯'),
|
||||
('\u{0305}', '\u{203E}'),
|
||||
('\u{0306}', '˘'),
|
||||
('\u{0307}', '˙'),
|
||||
('\u{0308}', '¨'),
|
||||
('\u{030C}', 'ˇ'),
|
||||
('\u{0301}', '´'),
|
||||
('\u{0300}', '`'),
|
||||
('\u{030A}', '˚'),
|
||||
('\u{20D7}', '→'),
|
||||
('\u{20D6}', '←'),
|
||||
('\u{20E1}', '↔'),
|
||||
('\u{20D1}', '⇀'),
|
||||
('\u{20D0}', '↼'),
|
||||
];
|
||||
|
||||
fn spacing_accent(c: char) -> char {
|
||||
ACCENTS.iter().find(|(comb, _)| *comb == c).map(|(_, s)| *s).unwrap_or(c)
|
||||
}
|
||||
|
||||
fn combining_accent(c: char) -> Option<char> {
|
||||
let c = match c {
|
||||
'ˆ' => '^',
|
||||
'˜' => '~',
|
||||
'‾' => '\u{203E}',
|
||||
'\u{20D7}' | '\u{0302}' | '\u{0303}' | '\u{0307}' | '\u{0308}' => return Some(c),
|
||||
c => c,
|
||||
};
|
||||
ACCENTS.iter().find(|(_, s)| *s == c).map(|(comb, _)| *comb)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reader
|
||||
|
||||
/// Most elements read from one `<math>` element.
|
||||
pub const MAX_NODES: usize = 20_000;
|
||||
|
||||
/// A parsed MathML element (namespace prefixes dropped, names lowercase).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct El {
|
||||
pub name: String,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub children: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Item {
|
||||
El(El),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
impl El {
|
||||
fn attr(&self, name: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
fn els(&self) -> impl Iterator<Item = &El> {
|
||||
self.children.iter().filter_map(|c| match c {
|
||||
Item::El(e) => Some(e),
|
||||
Item::Text(_) => None,
|
||||
})
|
||||
}
|
||||
/// The text inside, all descendants (depth-limited).
|
||||
pub fn text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
text_into(self, &mut s, 0);
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn text_into(e: &El, s: &mut String, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in &e.children {
|
||||
match c {
|
||||
Item::Text(t) => s.push_str(t),
|
||||
Item::El(c) => text_into(c, s, depth + 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The element name without a namespace prefix (`m:math`, `mml:mfrac`).
|
||||
pub fn local(name: &str) -> &str {
|
||||
name.rsplit(':').next().unwrap_or(name)
|
||||
}
|
||||
|
||||
/// The linear format of a `<math>` element, and whether it's a display equation. `None` when it
|
||||
/// holds nothing.
|
||||
pub fn read(math: &El) -> Option<(String, bool)> {
|
||||
let display = math.attr("display").is_some_and(|d| d.eq_ignore_ascii_case("block")) || math.attr("mode").is_some_and(|d| d == "display");
|
||||
let linear = linear_of(math);
|
||||
let linear: String = linear.chars().filter(|c| !c.is_control()).collect();
|
||||
let linear = linear.trim().to_string();
|
||||
(!linear.is_empty()).then_some((linear, display))
|
||||
}
|
||||
|
||||
fn linear_of(math: &El) -> String {
|
||||
// Our own copy carries the linear format exactly.
|
||||
if let Some(sem) = math.els().find(|e| local(&e.name) == "semantics") {
|
||||
let ann = |enc: &dyn Fn(&str) -> bool| {
|
||||
sem.els()
|
||||
.filter(|e| local(&e.name) == "annotation")
|
||||
.find(|e| e.attr("encoding").is_some_and(|v| enc(&v.to_ascii_lowercase())))
|
||||
.map(El::text)
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
};
|
||||
if let Some(t) = ann(&|v| v == UNICODEMATH) {
|
||||
return t;
|
||||
}
|
||||
let tree = to_tree(math);
|
||||
if !tree.is_empty() {
|
||||
return to_linear(&tree);
|
||||
}
|
||||
if let Some(t) = ann(&|v| v.contains("tex")) {
|
||||
return to_linear(&wordcraft_doc::math_latex::parse_latex(&t));
|
||||
}
|
||||
return String::new();
|
||||
}
|
||||
to_linear(&to_tree(math))
|
||||
}
|
||||
|
||||
/// A MathML element's children as an equation tree.
|
||||
pub fn to_tree(math: &El) -> Arg {
|
||||
let mut budget = MAX_NODES;
|
||||
wordcraft_doc::math::merge_runs(children(math, 0, &mut budget))
|
||||
}
|
||||
|
||||
fn children(e: &El, depth: usize, budget: &mut usize) -> Arg {
|
||||
let els: Vec<&El> = e.els().collect();
|
||||
seq(&els, depth, budget)
|
||||
}
|
||||
|
||||
/// A sequence of elements; an n-ary operator without an operand takes the next element.
|
||||
fn seq(els: &[&El], depth: usize, budget: &mut usize) -> Arg {
|
||||
let mut out: Arg = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(el) = els.get(i) {
|
||||
i += 1;
|
||||
// A function name (`<mi>sin</mi>`) applies to the next element even without U+2061,
|
||||
// so it doesn't merge with it into `sinx`. Before an operator (`(`, U+2061) the runs
|
||||
// merge and the linear format finds the function itself.
|
||||
if local(&el.name) == "mi"
|
||||
&& let Some(next) = els.get(i).filter(|n| local(&n.name) != "mo")
|
||||
{
|
||||
let t = el.text();
|
||||
let t = t.trim();
|
||||
if FUNCTION_NAMES.contains(&t) && *budget > 0 {
|
||||
*budget -= 1;
|
||||
i += 1;
|
||||
out.push(MNode::Func { name: vec![MNode::Run(MRun::plain(t))], e: elem(next, depth + 1, budget) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut a = elem(el, depth, budget);
|
||||
if let [MNode::Nary { e, .. }] = a.as_mut_slice()
|
||||
&& e.is_empty()
|
||||
&& let Some(next) = els.get(i)
|
||||
{
|
||||
i += 1;
|
||||
*e = elem(next, depth, budget);
|
||||
}
|
||||
out.extend(a);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `k`th child element as an argument.
|
||||
fn nth(e: &El, k: usize, depth: usize, budget: &mut usize) -> Arg {
|
||||
e.els().nth(k).map(|c| elem(c, depth, budget)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A lone operator character (`<mo>∑</mo>`).
|
||||
fn lone_char(a: &[MNode]) -> Option<char> {
|
||||
match a {
|
||||
[MNode::Run(r)] => {
|
||||
let mut cs = r.text.trim().chars();
|
||||
let c = cs.next()?;
|
||||
cs.next().is_none().then_some(c)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain word (`lim`, `max`).
|
||||
fn word(a: &[MNode]) -> bool {
|
||||
matches!(a, [MNode::Run(r)] if r.text.chars().count() > 1 && r.text.chars().all(char::is_alphabetic))
|
||||
}
|
||||
|
||||
fn elem(e: &El, depth: usize, budget: &mut usize) -> Arg {
|
||||
if depth > MAX_DEPTH || *budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
*budget -= 1;
|
||||
let d = depth + 1;
|
||||
match local(&e.name) {
|
||||
"mi" | "mn" | "mo" | "ms" => {
|
||||
let t = e.text();
|
||||
let t = t.trim();
|
||||
if t.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut r = MRun::new(t);
|
||||
if local(&e.name) == "mi" && e.attr("mathvariant") == Some("normal") && t.chars().count() == 1 {
|
||||
r.sty = Some(MSty::Plain);
|
||||
}
|
||||
vec![MNode::Run(r)]
|
||||
}
|
||||
"mtext" => {
|
||||
let t = e.text();
|
||||
if t.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![MNode::Run(MRun { text: t, nor: true, ..Default::default() })]
|
||||
}
|
||||
"mfrac" => {
|
||||
let thin = e.attr("linethickness").is_some_and(|t| t.trim().trim_end_matches(|c: char| c.is_alphabetic()).trim() == "0");
|
||||
let kind = if e.attr("bevelled") == Some("true") {
|
||||
FracKind::Skewed
|
||||
} else if thin {
|
||||
FracKind::NoBar
|
||||
} else {
|
||||
FracKind::Bar
|
||||
};
|
||||
vec![MNode::Frac { kind, num: nth(e, 0, d, budget), den: nth(e, 1, d, budget) }]
|
||||
}
|
||||
"msqrt" => vec![MNode::Rad { deg: Vec::new(), deg_hide: true, e: children(e, d, budget) }],
|
||||
"mroot" => vec![MNode::Rad { deg: nth(e, 1, d, budget), deg_hide: false, e: nth(e, 0, d, budget) }],
|
||||
"msup" | "msub" | "msubsup" => {
|
||||
let name = local(&e.name);
|
||||
let base = nth(e, 0, d, budget);
|
||||
let (sub, sup) = match name {
|
||||
"msup" => (Vec::new(), nth(e, 1, d, budget)),
|
||||
"msub" => (nth(e, 1, d, budget), Vec::new()),
|
||||
_ => (nth(e, 1, d, budget), nth(e, 2, d, budget)),
|
||||
};
|
||||
if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) {
|
||||
return vec![nary(c, LimLoc::SubSup, sub, sup)];
|
||||
}
|
||||
let kind = match name {
|
||||
"msup" => ScriptKind::Sup,
|
||||
"msub" => ScriptKind::Sub,
|
||||
_ => ScriptKind::SubSup,
|
||||
};
|
||||
vec![MNode::Script { kind, base, sub, sup }]
|
||||
}
|
||||
"munder" | "mover" | "munderover" => {
|
||||
let name = local(&e.name);
|
||||
let base = nth(e, 0, d, budget);
|
||||
let (under, over) = match name {
|
||||
"munder" => (nth(e, 1, d, budget), Vec::new()),
|
||||
"mover" => (Vec::new(), nth(e, 1, d, budget)),
|
||||
_ => (nth(e, 1, d, budget), nth(e, 2, d, budget)),
|
||||
};
|
||||
if let Some(c) = lone_char(&base).filter(|c| is_nary_char(*c)) {
|
||||
return vec![nary(c, LimLoc::UndOvr, under, over)];
|
||||
}
|
||||
if name == "munderover" {
|
||||
let inner = MNode::Lim { upper: false, e: base, lim: under };
|
||||
return vec![MNode::Lim { upper: true, e: vec![inner], lim: over }];
|
||||
}
|
||||
let upper = name == "mover";
|
||||
let mark = if upper { lone_char(&over) } else { lone_char(&under) };
|
||||
match mark {
|
||||
Some('\u{203E}' | '¯' | '‾') if upper => vec![MNode::Bar { top: true, e: base }],
|
||||
Some('_' | '\u{203E}' | '¯') if !upper => vec![MNode::Bar { top: false, e: base }],
|
||||
Some(c @ ('⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵' | '←' | '→' | '↔' | '⇐' | '⇒' | '⇔')) if !word(&base) => {
|
||||
vec![MNode::GroupChr { chr: c, top: upper, e: base }]
|
||||
}
|
||||
Some(c) if upper && combining_accent(c).is_some() => vec![MNode::Acc { chr: combining_accent(c).unwrap_or(c), e: base }],
|
||||
_ => vec![MNode::Lim { upper, e: base, lim: if upper { over } else { under } }],
|
||||
}
|
||||
}
|
||||
"mmultiscripts" => {
|
||||
let els: Vec<&El> = e.els().collect();
|
||||
let base = els.first().map(|b| elem(b, d, budget)).unwrap_or_default();
|
||||
let pre_at = els.iter().position(|c| local(&c.name) == "mprescripts");
|
||||
let script =
|
||||
|k: usize, budget: &mut usize| els.get(k).filter(|c| local(&c.name) != "none").map(|c| elem(c, d, budget)).unwrap_or_default();
|
||||
match pre_at {
|
||||
Some(p) => {
|
||||
let sub = script(p + 1, budget);
|
||||
let sup = script(p + 2, budget);
|
||||
vec![MNode::Script { kind: ScriptKind::Pre, base, sub, sup }]
|
||||
}
|
||||
None => {
|
||||
let sub = script(1, budget);
|
||||
let sup = script(2, budget);
|
||||
let kind = match (sub.is_empty(), sup.is_empty()) {
|
||||
(true, true) => return base,
|
||||
(false, true) => ScriptKind::Sub,
|
||||
(true, false) => ScriptKind::Sup,
|
||||
(false, false) => ScriptKind::SubSup,
|
||||
};
|
||||
vec![MNode::Script { kind, base, sub, sup }]
|
||||
}
|
||||
}
|
||||
}
|
||||
"mfenced" => {
|
||||
let first = |a: &str, dflt: char| e.attr(a).map(|v| v.trim().chars().next()).unwrap_or(Some(dflt));
|
||||
let beg = first("open", '(');
|
||||
let end = first("close", ')');
|
||||
let sep = first("separators", ',');
|
||||
let elems: Vec<Arg> = e.els().map(|c| elem(c, d, budget)).collect();
|
||||
vec![MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }]
|
||||
}
|
||||
"mtable" => {
|
||||
let rows: Vec<Vec<Arg>> = e
|
||||
.els()
|
||||
.filter(|r| matches!(local(&r.name), "mtr" | "mlabeledtr"))
|
||||
.take(256)
|
||||
.map(|r| r.els().filter(|c| local(&c.name) == "mtd").take(256).map(|c| children(c, d, budget)).collect())
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// One column is an equation array only when marked as one (`displaystyle`, alignment
|
||||
// marks, labelled rows, as TeX's align and our own writer give); else a column vector.
|
||||
let marked =
|
||||
e.attr("displaystyle").is_some_and(|v| v.trim() == "true") || e.els().any(|r| local(&r.name) == "mlabeledtr") || has_align_mark(e, 0);
|
||||
if marked && rows.iter().all(|r| r.len() <= 1) {
|
||||
return vec![MNode::EqArr { rows: rows.into_iter().map(|mut r| r.pop().unwrap_or_default()).collect() }];
|
||||
}
|
||||
vec![MNode::Matrix { rows, col_jc: Vec::new() }]
|
||||
}
|
||||
"mphantom" => vec![MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: children(e, d, budget) }],
|
||||
"menclose" => {
|
||||
let n = e.attr("notation").unwrap_or("longdiv");
|
||||
let has = |k: &str| n.split_whitespace().any(|w| w == k);
|
||||
if has("box") || has("roundedbox") {
|
||||
return vec![MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: children(e, d, budget) }];
|
||||
}
|
||||
children(e, d, budget)
|
||||
}
|
||||
// The first child of `semantics` is the presentation; annotations aren't content.
|
||||
"semantics" => e.els().next().map(|c| elem(c, d, budget)).unwrap_or_default(),
|
||||
"annotation" | "annotation-xml" | "mspace" | "none" | "mprescripts" | "maligngroup" | "malignmark" => Vec::new(),
|
||||
_ => children(e, d, budget),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` holds a `maligngroup` or `malignmark` near the top (in a cell or a row inside it).
|
||||
fn has_align_mark(e: &El, depth: usize) -> bool {
|
||||
depth <= 4 && e.els().any(|c| matches!(local(&c.name), "maligngroup" | "malignmark") || has_align_mark(c, depth + 1))
|
||||
}
|
||||
|
||||
fn nary(chr: char, lim_loc: LimLoc, sub: Arg, sup: Arg) -> MNode {
|
||||
let lim_loc = Some(lim_loc).filter(|l| *l != if is_integral(chr) { LimLoc::SubSup } else { LimLoc::UndOvr });
|
||||
MNode::Nary { chr, lim_loc, grow: false, sub_hide: sub.is_empty(), sup_hide: sup.is_empty(), sub, sup, e: Vec::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::math::parse_linear;
|
||||
|
||||
fn ml(linear: &str) -> String {
|
||||
to_mathml(&parse_linear(linear), false, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_split_into_identifiers_numbers_and_operators() {
|
||||
let m = ml("y=2x");
|
||||
assert!(m.starts_with("<math xmlns=\"http://www.w3.org/1998/Math/MathML\">"), "{m}");
|
||||
assert!(m.contains("<mi>y</mi><mo>=</mo><mn>2</mn><mi>x</mi>"), "{m}");
|
||||
assert!(m.contains("<annotation encoding=\"application/x-unicodemath\">y=2x</annotation>"), "{m}");
|
||||
assert!(to_mathml(&parse_linear("x"), true, "").contains("display=\"block\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structures_become_their_elements() {
|
||||
for (linear, want) in [
|
||||
("a/b", "<mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac>"),
|
||||
("x^2", "<msup><mrow><mi>x</mi></mrow><mrow><mn>2</mn></mrow></msup>"),
|
||||
("x_s", "<msub><mrow><mi>x</mi></mrow><mrow><mi>s</mi></mrow></msub>"),
|
||||
("x_i^2", "<msubsup>"),
|
||||
("√x", "<msqrt><mi>x</mi></msqrt>"),
|
||||
("√(3&x)", "<mroot><mrow><mi>x</mi></mrow><mrow><mn>3</mn></mrow></mroot>"),
|
||||
("∑_(i=1)^n▒i", "<munderover><mo largeop=\"true\" movablelimits=\"false\">∑</mo>"),
|
||||
("∫_0^1▒x", "<msubsup><mo largeop=\"true\" movablelimits=\"false\">∫</mo>"),
|
||||
("(a+b)", "<mo fence=\"true\" form=\"prefix\">(</mo>"),
|
||||
("sinx", "<mi>sin</mi><mo>\u{2061}</mo>"),
|
||||
("■(a&b@c&d)", "<mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr>"),
|
||||
] {
|
||||
let m = ml(linear);
|
||||
assert!(m.contains(want), "{linear}: {m}");
|
||||
}
|
||||
let acc = to_mathml(&[MNode::Acc { chr: '\u{0302}', e: parse_linear("a") }], false, "");
|
||||
assert!(acc.contains("<mover accent=\"true\"><mrow><mi>a</mi></mrow><mo>^</mo></mover>"), "{acc}");
|
||||
let bar = to_mathml(&[MNode::Bar { top: true, e: parse_linear("a") }], false, "");
|
||||
assert!(bar.contains("<mo>\u{203E}</mo></mover>"), "{bar}");
|
||||
let lim = to_mathml(&[MNode::Lim { upper: false, e: parse_linear("lim"), lim: parse_linear("n→∞") }], false, "");
|
||||
assert!(lim.contains("<munder><mi>lim</mi><mrow><mi>n</mi><mo>→</mo><mi>∞</mi></mrow></munder>"), "{lim}");
|
||||
let bb = to_mathml(&[MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: parse_linear("a") }], false, "");
|
||||
assert!(bb.contains("<menclose notation=\"box\">"), "{bb}");
|
||||
let gc = to_mathml(&[MNode::GroupChr { chr: '⏟', top: false, e: parse_linear("a") }], false, "");
|
||||
assert!(gc.contains("<munder><mrow><mi>a</mi></mrow><mo stretchy=\"true\">⏟</mo></munder>"), "{gc}");
|
||||
let pre = to_mathml(&[MNode::Script { kind: ScriptKind::Pre, base: parse_linear("C"), sub: parse_linear("n"), sup: vec![] }], false, "");
|
||||
assert!(pre.contains("<mprescripts/>"), "{pre}");
|
||||
let eq = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x&=1"), parse_linear("y&=2")] }], false, "");
|
||||
assert!(eq.contains("<mtd><mi>x</mi><mo>=</mo><mn>1</mn></mtd>") && !eq.contains("<mo>&</mo>"), "{eq}");
|
||||
let ph = to_mathml(&[MNode::Phant { show: false, zero_wid: true, zero_asc: false, zero_desc: false, e: parse_linear("a") }], false, "");
|
||||
assert!(ph.contains("<mpadded width=\"0\"><mphantom>"), "{ph}");
|
||||
let styled = to_mathml(
|
||||
&[
|
||||
MNode::Run(MRun { text: "R".into(), scr: MScr::DoubleStruck, ..Default::default() }),
|
||||
MNode::Run(MRun::plain("d")),
|
||||
MNode::Run(MRun { text: "speed".into(), nor: true, ..Default::default() }),
|
||||
],
|
||||
false,
|
||||
"",
|
||||
);
|
||||
assert!(
|
||||
styled.contains("<mi>ℝ</mi>") && styled.contains("<mi mathvariant=\"normal\">d</mi>") && styled.contains("<mtext>speed</mtext>"),
|
||||
"{styled}"
|
||||
);
|
||||
// Text is escaped.
|
||||
assert!(ml("a<b").contains("<mo><</mo>"));
|
||||
}
|
||||
|
||||
fn pasted(html: &str) -> Vec<(String, bool)> {
|
||||
let flow = crate::html::parse(html);
|
||||
let mut out = Vec::new();
|
||||
for b in &flow.blocks {
|
||||
if let crate::model::FBlock::Para(p) = b {
|
||||
for i in &p.inlines {
|
||||
if let crate::model::Inline::Equation { linear, display } = i {
|
||||
out.push((linear.clone(), *display));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_round_trips_equations() {
|
||||
for (linear, display) in [("y=2x_s", false), ("x=(-b±√(b^2-4ac))/2a", true), ("∑_(k=1)^n▒k", false), ("■(a&b@c&d)", false)] {
|
||||
let html = crate::html::inlines_html(&[crate::model::Inline::Equation { linear: linear.into(), display }]);
|
||||
assert!(html.starts_with("<math"), "{html}");
|
||||
assert_eq!(pasted(&format!("<p>Before {html} after</p>")), vec![(linear.to_string(), display)], "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_mathml_is_read_from_its_elements() {
|
||||
let cases = [
|
||||
("<math><mi>y</mi><mo>=</mo><mn>2</mn><msub><mi>x</mi><mi>s</mi></msub></math>", "y=2x_s"),
|
||||
("<math display='block'><mfrac><mi>a</mi><mrow><mi>b</mi><mo>+</mo><mn>1</mn></mrow></mfrac></math>", "a/(b+1)"),
|
||||
("<math><msqrt><mi>x</mi></msqrt></math>", "√x"),
|
||||
("<math><munderover><mo>∑</mo><mrow><mi>k</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></munderover><mi>k</mi></math>", "∑_(k=1)^n▒k"),
|
||||
("<mml:math><mml:msup><mml:mi>e</mml:mi><mml:mi>x</mml:mi></mml:msup></mml:math>", "e^x"),
|
||||
// A TeX annotation is used when the presentation is empty.
|
||||
("<math><semantics><mrow></mrow><annotation encoding='application/x-tex'>\\frac{1}{2}</annotation></semantics></math>", "1/2"),
|
||||
];
|
||||
for (html, want) in cases {
|
||||
let got = pasted(html);
|
||||
assert_eq!(got.first().map(|g| g.0.as_str()), Some(want), "{html}");
|
||||
}
|
||||
assert!(pasted("<math display=\"block\"><mi>x</mi></math>").first().is_some_and(|g| g.1));
|
||||
// Text after the equation still arrives; an unclosed element ends with the input.
|
||||
let f = crate::html::parse("<p><math><mi>x</mi></math> tail</p>");
|
||||
assert!(crate::model::to_doc(&f).plain_text(wordcraft_doc::StoryRef::Body).contains("tail"));
|
||||
assert!(!pasted("<p><math><mfrac><mi>x").is_empty());
|
||||
// Hostile nesting is bounded.
|
||||
let deep = format!("<math>{}<mi>x</mi>{}</math>", "<mrow>".repeat(5000), "</mrow>".repeat(5000));
|
||||
assert!(pasted(&deep).len() <= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_over_the_token_cap_is_skipped_to_its_end() {
|
||||
// Past the cap, the rest of the element is dropped, not read as body text.
|
||||
let html = format!("<p><math>{}<mrow><mi>z</mi></mrow></math> tail</p>", "<mi>y</mi>".repeat(MAX_NODES * 2));
|
||||
let flow = crate::html::parse(&html);
|
||||
let mut text = String::new();
|
||||
for b in &flow.blocks {
|
||||
if let crate::model::FBlock::Para(p) = b {
|
||||
for i in &p.inlines {
|
||||
if let crate::model::Inline::Text(t, _) = i {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text.trim(), "tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_names_apply_without_the_invisible_operator() {
|
||||
let cases = [
|
||||
("<math><mi>sin</mi><mi>x</mi></math>", "sin\u{2061}x"),
|
||||
("<math><mi>log</mi><mrow><mi>a</mi><mi>b</mi></mrow></math>", "log\u{2061}ab"),
|
||||
("<math><mi>cos</mi><mo>\u{2061}</mo><mi>x</mi></math>", "cos\u{2061}x"),
|
||||
("<math><mi>ab</mi><mi>x</mi></math>", "abx"),
|
||||
];
|
||||
for (html, want) in cases {
|
||||
assert_eq!(pasted(html).first().map(|g| g.0.as_str()), Some(want), "{html}");
|
||||
}
|
||||
let tree = to_tree(&El {
|
||||
name: "math".into(),
|
||||
attrs: vec![],
|
||||
children: vec![
|
||||
Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("sin".into())] }),
|
||||
Item::El(El { name: "mi".into(), attrs: vec![], children: vec![Item::Text("x".into())] }),
|
||||
],
|
||||
});
|
||||
assert!(matches!(tree.as_slice(), [MNode::Func { .. }]), "{tree:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_column_tables_are_column_vectors_unless_marked() {
|
||||
let tree = |html: &str| {
|
||||
let linear = pasted(html).first().map(|g| g.0.clone()).unwrap_or_default();
|
||||
wordcraft_doc::math::parse_linear(&linear)
|
||||
};
|
||||
let vector = tree("<math><mtable><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>");
|
||||
assert!(matches!(vector.as_slice(), [MNode::Matrix { rows, .. }] if rows.len() == 2), "{vector:?}");
|
||||
for marked in [
|
||||
"<math><mtable displaystyle='true'><mtr><mtd><mi>a</mi></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>",
|
||||
"<math><mtable><mtr><mtd><mi>a</mi><maligngroup/><mo>=</mo><mn>1</mn></mtd></mtr><mtr><mtd><mi>b</mi></mtd></mtr></mtable></math>",
|
||||
] {
|
||||
let t = tree(marked);
|
||||
assert!(matches!(t.as_slice(), [MNode::EqArr { rows }] if rows.len() == 2), "{marked}: {t:?}");
|
||||
}
|
||||
// Our own equation arrays read back as equation arrays from their elements alone.
|
||||
let ml = to_mathml(&[MNode::EqArr { rows: vec![parse_linear("x=1"), parse_linear("y=2")] }], false, "");
|
||||
let ml = ml.replace("application/x-unicodemath", "x-ignored");
|
||||
let t = tree(&ml);
|
||||
assert!(matches!(t.as_slice(), [MNode::EqArr { .. }]), "{ml}: {t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_bounded() {
|
||||
let mut n = parse_linear("x");
|
||||
for _ in 0..500 {
|
||||
n = vec![MNode::Frac { kind: FracKind::Bar, num: n, den: parse_linear("y") }];
|
||||
}
|
||||
let m = to_mathml(&n, false, "z");
|
||||
assert!(m.ends_with("</math>"));
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,19 @@ pub struct ListInfo {
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
/// Paragraph spacing and indents (points) that a source states on the paragraph itself (RTF);
|
||||
/// left out, they come from the style.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Spacing {
|
||||
pub before: Option<f32>,
|
||||
pub after: Option<f32>,
|
||||
pub line: Option<wordcraft_doc::props::LineSpacing>,
|
||||
pub left: Option<f32>,
|
||||
pub right: Option<f32>,
|
||||
/// First-line indent; negative = hanging.
|
||||
pub first: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Para {
|
||||
pub kind: Kind,
|
||||
@@ -96,6 +109,9 @@ pub struct Para {
|
||||
pub align: Option<Align>,
|
||||
pub page_break: bool,
|
||||
pub inlines: Vec<Inline>,
|
||||
pub spacing: Spacing,
|
||||
/// Borders the source puts on the paragraph itself (RTF `\brdrb`…).
|
||||
pub borders: Option<wordcraft_doc::props::Borders>,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
@@ -126,6 +142,22 @@ impl Para {
|
||||
}
|
||||
s
|
||||
}
|
||||
/// [`Self::text`] as it reads on screen: text set in a symbol font (Symbol, Wingdings…) as
|
||||
/// its Unicode equivalents ([`wordcraft_doc::symbol_fonts`]), for plain-text export.
|
||||
pub fn shown_text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for i in &self.inlines {
|
||||
match i {
|
||||
Inline::Text(t, f) => match f.font.as_deref() {
|
||||
Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)),
|
||||
_ => s.push_str(t),
|
||||
},
|
||||
Inline::Equation { linear: t, .. } => s.push_str(t),
|
||||
Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inlines.iter().all(|i| matches!(i, Inline::Text(t, _) if t.is_empty()))
|
||||
}
|
||||
@@ -171,11 +203,13 @@ pub struct Cell {
|
||||
pub covered: bool,
|
||||
pub header: bool,
|
||||
pub shading: Option<Rgb>,
|
||||
/// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style.
|
||||
pub borders: Option<wordcraft_doc::props::Borders>,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None }
|
||||
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +399,8 @@ impl Builder<'_> {
|
||||
out.props.style = style;
|
||||
if p.kind == Kind::Rule {
|
||||
out.props.borders = Some(rule_borders());
|
||||
} else if p.borders.is_some() {
|
||||
out.props.borders = p.borders;
|
||||
}
|
||||
if let (Some(li), Some(num)) = (p.list, list_num) {
|
||||
if out.props.style.is_none() {
|
||||
@@ -373,6 +409,13 @@ impl Builder<'_> {
|
||||
out.props.numbering = Some(NumRef { num, level: li.level.min(8) });
|
||||
}
|
||||
out.props.align = p.align;
|
||||
let sp = p.spacing;
|
||||
out.props.space_before = sp.before.or(out.props.space_before);
|
||||
out.props.space_after = sp.after.or(out.props.space_after);
|
||||
out.props.line_spacing = sp.line.or(out.props.line_spacing);
|
||||
out.props.indent_left = sp.left.or(out.props.indent_left);
|
||||
out.props.indent_right = sp.right.or(out.props.indent_right);
|
||||
out.props.indent_first = sp.first.or(out.props.indent_first);
|
||||
if p.page_break {
|
||||
out.props.page_break_before = Some(true);
|
||||
}
|
||||
@@ -528,7 +571,7 @@ impl Builder<'_> {
|
||||
VMerge::None
|
||||
};
|
||||
row.cells.push(wordcraft_doc::Cell {
|
||||
props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, ..Default::default() },
|
||||
props: CellProps { width: Some(w), span: span as u32, vmerge, shading: c.shading, borders: c.borders, ..Default::default() },
|
||||
blocks,
|
||||
controls: Default::default(),
|
||||
});
|
||||
@@ -548,7 +591,13 @@ fn finite_or(v: f32, d: f32) -> f32 {
|
||||
|
||||
/// Build a document from a flow.
|
||||
pub fn to_doc(flow: &Flow) -> Document {
|
||||
to_doc_in(flow, wordcraft_doc::SectionProps::default())
|
||||
}
|
||||
|
||||
/// [`to_doc`] on a page of the source's own size and margins (`section`), which tables size to.
|
||||
pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.last_section = section;
|
||||
doc.numbering = Default::default();
|
||||
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
|
||||
if uses_code {
|
||||
@@ -567,6 +616,16 @@ pub fn to_doc(flow: &Flow) -> Document {
|
||||
doc
|
||||
}
|
||||
|
||||
/// `blocks` as document blocks of `doc` (their pictures go into its media), for a header,
|
||||
/// footer or other part an importer adds itself.
|
||||
pub fn part_blocks(doc: &mut Document, blocks: &[FBlock]) -> Blocks {
|
||||
let mut out = Builder { doc }.blocks(blocks, 0);
|
||||
if out.is_empty() {
|
||||
out.push(para_block(Paragraph::new()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn uses_kind(blocks: &[FBlock], k: Kind, depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& blocks.iter().any(|b| match b {
|
||||
@@ -674,7 +733,15 @@ fn media_ext(key: &str, data: &[u8]) -> String {
|
||||
pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
let (kind, list) = para_kind(doc, p);
|
||||
let mut done: Vec<Para> = Vec::new();
|
||||
let mut out = Para { kind, list, align: p.props.align, page_break: p.props.page_break_before.unwrap_or(false), inlines: Vec::new() };
|
||||
let mut out = Para {
|
||||
kind,
|
||||
list,
|
||||
align: p.props.align,
|
||||
page_break: p.props.page_break_before.unwrap_or(false),
|
||||
inlines: Vec::new(),
|
||||
spacing: Spacing::default(),
|
||||
borders: None,
|
||||
};
|
||||
let in_code = kind == Kind::Code;
|
||||
let mut k = 0usize;
|
||||
for (range, props) in p.run_ranges() {
|
||||
@@ -686,7 +753,15 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
if c == PAGE_BREAK {
|
||||
if !hidden {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
let next = Para { kind, list: None, align: p.props.align, page_break: true, inlines: Vec::new() };
|
||||
let next = Para {
|
||||
kind,
|
||||
list: None,
|
||||
align: p.props.align,
|
||||
page_break: true,
|
||||
inlines: Vec::new(),
|
||||
spacing: Spacing::default(),
|
||||
borders: None,
|
||||
};
|
||||
let prev = std::mem::replace(&mut out, next);
|
||||
if prev.inlines.is_empty() {
|
||||
out.page_break |= prev.page_break;
|
||||
@@ -703,7 +778,8 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
|
||||
}
|
||||
let obj = p.objects.get(k);
|
||||
k += 1;
|
||||
if hidden {
|
||||
// Hidden text, or a hidden drawing: not in the copy.
|
||||
if hidden || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) {
|
||||
continue;
|
||||
}
|
||||
match obj {
|
||||
@@ -796,6 +872,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize) -> FTable {
|
||||
covered: c.props.vmerge == VMerge::Continue,
|
||||
header,
|
||||
shading: c.props.shading,
|
||||
borders: c.props.borders,
|
||||
});
|
||||
}
|
||||
rows.push(cells);
|
||||
|
||||
+613
-12
@@ -9,7 +9,9 @@
|
||||
//!
|
||||
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers,
|
||||
//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN`
|
||||
//! fallback skipping, `\'hh` (Windows-1252), the formatting words above, stylesheet-based
|
||||
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`) or the
|
||||
//! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when
|
||||
//! none is named), the formatting words above, stylesheet-based
|
||||
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
|
||||
@@ -18,7 +20,6 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
|
||||
use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img};
|
||||
use crate::txt::cp1252;
|
||||
|
||||
/// Deepest group nesting we track; deeper groups share the innermost state.
|
||||
const MAX_GROUPS: usize = 512;
|
||||
@@ -481,6 +482,37 @@ enum Dest {
|
||||
Bookmark,
|
||||
}
|
||||
|
||||
/// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages
|
||||
/// (`\headerf` first, `\headerl` left = even, `\header`/`\headerr` the others).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct HeaderFooter {
|
||||
footer: bool,
|
||||
pages: HfPages,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum HfPages {
|
||||
Default,
|
||||
First,
|
||||
Even,
|
||||
}
|
||||
|
||||
/// The reader's output so far, set aside while a header or footer is read.
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
body: Vec<FBlock>,
|
||||
para: Para,
|
||||
para_has: bool,
|
||||
table: Vec<Vec<Cell>>,
|
||||
row: Vec<Cell>,
|
||||
cell: Vec<FBlock>,
|
||||
defs: Vec<CellDef>,
|
||||
def_cur: CellDef,
|
||||
row_header: bool,
|
||||
rule_pending: bool,
|
||||
table_rights: Vec<Vec<i32>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct St {
|
||||
dest: Dest,
|
||||
@@ -496,6 +528,21 @@ struct St {
|
||||
outline: Option<u8>,
|
||||
pagebb: bool,
|
||||
field: Option<usize>,
|
||||
/// The code page of the current font's charset (`\fcharset`), when it names one.
|
||||
cp: Option<u16>,
|
||||
/// Space before/after (`\sb`, `\sa`), line spacing (`\sl`, `\slmult`) and indents
|
||||
/// (`\li`, `\ri`, `\fi`), twips.
|
||||
/// The paragraph's borders (`\brdrt`, `\brdrb`, `\brdrl`, `\brdrr`) and the side being
|
||||
/// described.
|
||||
borders: wordcraft_doc::props::Borders,
|
||||
border_side: Option<char>,
|
||||
sb: Option<i32>,
|
||||
sa: Option<i32>,
|
||||
sl: i32,
|
||||
slmult: bool,
|
||||
li: Option<i32>,
|
||||
ri: Option<i32>,
|
||||
fi: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for St {
|
||||
@@ -513,6 +560,16 @@ impl Default for St {
|
||||
outline: None,
|
||||
pagebb: false,
|
||||
field: None,
|
||||
cp: None,
|
||||
borders: Default::default(),
|
||||
border_side: None,
|
||||
sb: None,
|
||||
sa: None,
|
||||
sl: 0,
|
||||
slmult: false,
|
||||
li: None,
|
||||
ri: None,
|
||||
fi: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,6 +580,63 @@ struct CellDef {
|
||||
hmerge: u8,
|
||||
shading: Option<Rgb>,
|
||||
right: i32,
|
||||
/// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw`
|
||||
/// width and `\brdrcf` colour).
|
||||
borders: wordcraft_doc::props::Borders,
|
||||
}
|
||||
|
||||
/// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct PageGeom {
|
||||
w: Option<i32>,
|
||||
h: Option<i32>,
|
||||
left: Option<i32>,
|
||||
right: Option<i32>,
|
||||
top: Option<i32>,
|
||||
bottom: Option<i32>,
|
||||
header: Option<i32>,
|
||||
footer: Option<i32>,
|
||||
landscape: bool,
|
||||
}
|
||||
|
||||
impl PageGeom {
|
||||
/// The section properties these give, Word's defaults where the file names none.
|
||||
fn section(&self) -> wordcraft_doc::SectionProps {
|
||||
let mut s = wordcraft_doc::SectionProps::default();
|
||||
// Twips to points, ignoring nonsense (zero, negative, or beyond Word's 22").
|
||||
let pt = |v: Option<i32>, lo: f32, hi: f32| v.map(|t| t as f32 / 20.0).filter(|v| v.is_finite() && (lo..=hi).contains(v));
|
||||
if let Some(w) = pt(self.w, 72.0, 1584.0) {
|
||||
s.page_w = w;
|
||||
}
|
||||
if let Some(h) = pt(self.h, 72.0, 1584.0) {
|
||||
s.page_h = h;
|
||||
}
|
||||
if self.landscape && s.page_w < s.page_h {
|
||||
std::mem::swap(&mut s.page_w, &mut s.page_h);
|
||||
}
|
||||
s.landscape = s.page_w > s.page_h;
|
||||
for (v, f) in [
|
||||
(self.left, &mut s.margin_left),
|
||||
(self.right, &mut s.margin_right),
|
||||
(self.top, &mut s.margin_top),
|
||||
(self.bottom, &mut s.margin_bottom),
|
||||
(self.header, &mut s.header),
|
||||
(self.footer, &mut s.footer),
|
||||
] {
|
||||
if let Some(v) = pt(v, 0.0, 720.0) {
|
||||
*f = v;
|
||||
}
|
||||
}
|
||||
// Margins that leave no room for text fall back to Word's.
|
||||
let d = wordcraft_doc::SectionProps::default();
|
||||
if s.page_w - s.margin_left - s.margin_right < 72.0 {
|
||||
(s.margin_left, s.margin_right) = (d.margin_left, d.margin_right);
|
||||
}
|
||||
if s.page_h - s.margin_top - s.margin_bottom < 72.0 {
|
||||
(s.margin_top, s.margin_bottom) = (d.margin_top, d.margin_bottom);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -533,6 +647,26 @@ struct Reader {
|
||||
fonts: HashMap<i32, String>,
|
||||
font_cur: i32,
|
||||
font_name: String,
|
||||
/// Code pages of the fonts whose charset names one (`\fcharset204` → 1251).
|
||||
font_cps: HashMap<i32, u16>,
|
||||
font_cp_cur: Option<u16>,
|
||||
/// The document's code page (`\ansicpg`); 0 until it names one (Windows-1252 then).
|
||||
ansicpg: u16,
|
||||
/// The default font (`\deff`), whose charset applies before any `\f`.
|
||||
deff: i32,
|
||||
page: PageGeom,
|
||||
/// The header or footer being read: the group depth it started at, which one, and the
|
||||
/// body's output, put back when the group closes.
|
||||
hf: Option<(usize, HeaderFooter, Box<Output>)>,
|
||||
/// Headers and footers read, in order.
|
||||
hf_parts: Vec<(HeaderFooter, Vec<FBlock>)>,
|
||||
/// `\titlepg`: the first page has its own header and footer.
|
||||
title_page: bool,
|
||||
/// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width
|
||||
/// and colour follow.
|
||||
cell_side: Option<char>,
|
||||
/// Each table row's cell right edges (`\cellx`, twips), for the table's column grid.
|
||||
table_rights: Vec<Vec<i32>>,
|
||||
colors: Vec<Option<Rgb>>,
|
||||
color_cur: (u8, u8, u8, bool),
|
||||
styles: HashMap<i32, String>,
|
||||
@@ -581,6 +715,60 @@ impl Reader {
|
||||
self.stack.len()
|
||||
}
|
||||
|
||||
/// Set the output aside (a header or footer starts) or put it back (it ended), returning the
|
||||
/// other one.
|
||||
fn swap_output(&mut self, mut o: Output) -> Output {
|
||||
std::mem::swap(&mut o.body, &mut self.body);
|
||||
std::mem::swap(&mut o.para, &mut self.para);
|
||||
std::mem::swap(&mut o.para_has, &mut self.para_has);
|
||||
std::mem::swap(&mut o.table, &mut self.table);
|
||||
std::mem::swap(&mut o.row, &mut self.row);
|
||||
std::mem::swap(&mut o.cell, &mut self.cell);
|
||||
std::mem::swap(&mut o.defs, &mut self.defs);
|
||||
std::mem::swap(&mut o.def_cur, &mut self.def_cur);
|
||||
std::mem::swap(&mut o.row_header, &mut self.row_header);
|
||||
std::mem::swap(&mut o.rule_pending, &mut self.rule_pending);
|
||||
std::mem::swap(&mut o.table_rights, &mut self.table_rights);
|
||||
o
|
||||
}
|
||||
|
||||
/// The border of the cell side being described, else of the paragraph side.
|
||||
fn cell_border_slot(&mut self) -> Option<&mut Option<wordcraft_doc::props::Border>> {
|
||||
let (b, side) = match self.cell_side {
|
||||
Some(c) => (&mut self.def_cur.borders, c),
|
||||
None => (&mut self.st.borders, self.st.border_side?),
|
||||
};
|
||||
match side {
|
||||
't' => Some(&mut b.top),
|
||||
'l' => Some(&mut b.left),
|
||||
'b' => Some(&mut b.bottom),
|
||||
'r' => Some(&mut b.right),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A header or footer group starts: read it into an output of its own.
|
||||
fn start_header_footer(&mut self, which: HeaderFooter) {
|
||||
let body = self.swap_output(Output::default());
|
||||
self.hf = Some((self.depth(), which, Box::new(body)));
|
||||
self.st.intbl = false;
|
||||
}
|
||||
|
||||
/// The header or footer group closed: keep what it held and go back to the body.
|
||||
fn end_header_footer(&mut self) {
|
||||
let Some((_, which, body)) = self.hf.take() else { return };
|
||||
if self.para_has || !self.para.inlines.is_empty() {
|
||||
self.end_para(false);
|
||||
}
|
||||
self.close_table();
|
||||
let part = self.swap_output(*body);
|
||||
// A document names each one once; a repeat (another section's) replaces it.
|
||||
self.hf_parts.retain(|(w, _)| *w != which);
|
||||
if self.hf_parts.len() < 16 {
|
||||
self.hf_parts.push((which, part.body));
|
||||
}
|
||||
}
|
||||
|
||||
fn open(&mut self) {
|
||||
self.skip_chars = 0;
|
||||
if self.stack.len() >= MAX_GROUPS {
|
||||
@@ -631,15 +819,28 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
self.st = prev;
|
||||
if self.hf.as_ref().is_some_and(|(d, _, _)| self.depth() < *d) {
|
||||
self.end_header_footer();
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if !name.is_empty() && self.fonts.len() < 4096 {
|
||||
self.fonts.insert(self.font_cur, name);
|
||||
if let Some(cp) = self.font_cp_cur.take() {
|
||||
self.font_cps.insert(self.font_cur, cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte of 8-bit text (`\'hh` or raw) as a char, in the current font's code page, else the
|
||||
/// default font's, else the document's.
|
||||
fn byte_char(&self, b: u8) -> char {
|
||||
let cp = self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg });
|
||||
wordcraft_doc::encoding::windows_byte(cp, b)
|
||||
}
|
||||
|
||||
fn commit_style(&mut self) {
|
||||
let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if self.styles.len() < 4096 {
|
||||
@@ -779,6 +980,8 @@ impl Reader {
|
||||
self.rule_pending = false;
|
||||
p.align = self.st.align;
|
||||
p.page_break = self.st.pagebb;
|
||||
p.spacing = self.spacing();
|
||||
p.borders = self.st.borders.any_visible().then_some(self.st.borders);
|
||||
if let Some(ls) = self.st.ls {
|
||||
let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id));
|
||||
let lv = self.st.ilvl.min(8);
|
||||
@@ -816,6 +1019,32 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current paragraph's spacing and indents in points.
|
||||
fn spacing(&self) -> model::Spacing {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let pt = |v: i32| (v as f32 / 20.0).clamp(-1584.0, 1584.0);
|
||||
let gap = |v: Option<i32>| v.map(|v| pt(v).max(0.0));
|
||||
let sl = self.st.sl;
|
||||
let line = if sl == 0 {
|
||||
None
|
||||
} else if sl < 0 {
|
||||
Some(LineSpacing::Exactly(pt(sl.saturating_neg()).max(1.0)))
|
||||
} else if self.st.slmult {
|
||||
// \sl240\slmult1 is single spacing.
|
||||
Some(LineSpacing::Multiple((sl as f32 / 240.0).clamp(0.06, 132.0)))
|
||||
} else {
|
||||
Some(LineSpacing::AtLeast(pt(sl)))
|
||||
};
|
||||
model::Spacing {
|
||||
before: gap(self.st.sb),
|
||||
after: gap(self.st.sa),
|
||||
line,
|
||||
left: self.st.li.map(pt),
|
||||
right: self.st.ri.map(pt),
|
||||
first: self.st.fi.map(pt),
|
||||
}
|
||||
}
|
||||
|
||||
fn end_cell(&mut self) {
|
||||
self.end_para(true);
|
||||
let blocks = std::mem::take(&mut self.cell);
|
||||
@@ -831,9 +1060,19 @@ impl Reader {
|
||||
let mut cells = std::mem::take(&mut self.row);
|
||||
let defs = self.defs.clone();
|
||||
let mut out: Vec<Cell> = Vec::new();
|
||||
let mut rights: Vec<i32> = Vec::new();
|
||||
for (i, mut c) in cells.drain(..).enumerate() {
|
||||
let d = defs.get(i).cloned().unwrap_or_default();
|
||||
// A merged-in cell widens the cell it joins.
|
||||
if d.hmerge == 2
|
||||
&& let Some(last) = rights.last_mut()
|
||||
{
|
||||
*last = d.right;
|
||||
} else {
|
||||
rights.push(d.right);
|
||||
}
|
||||
c.shading = d.shading;
|
||||
c.borders = d.borders.any_visible().then_some(d.borders);
|
||||
c.header = self.row_header;
|
||||
match d.vmerge {
|
||||
1 => c.rowspan = 2,
|
||||
@@ -849,7 +1088,9 @@ impl Reader {
|
||||
out.push(c);
|
||||
}
|
||||
if !out.is_empty() && self.table.len() < wordcraft_doc::table::MAX_ROWS {
|
||||
rights.truncate(out.len());
|
||||
self.table.push(out);
|
||||
self.table_rights.push(rights);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,8 +1103,11 @@ impl Reader {
|
||||
self.end_row();
|
||||
}
|
||||
if !self.table.is_empty() {
|
||||
let rows = std::mem::take(&mut self.table);
|
||||
self.body.push(FBlock::Table(FTable { rows, widths: Vec::new(), borderless: false }));
|
||||
let mut rows = std::mem::take(&mut self.table);
|
||||
let rights = std::mem::take(&mut self.table_rights);
|
||||
let widths = column_grid(&mut rows, &rights);
|
||||
// Word draws only the borders the cells name: no grid style, each cell its own.
|
||||
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +1147,18 @@ impl Reader {
|
||||
"shppict" => return,
|
||||
"pict" => Some(Dest::Pict),
|
||||
"bkmkstart" => Some(Dest::Bookmark),
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf"
|
||||
if self.hf.is_none() && self.st.dest == Dest::Body && !self.st.intbl =>
|
||||
{
|
||||
let footer = w.starts_with("footer");
|
||||
let pages = match w.strip_prefix(if footer { "footer" } else { "header" }) {
|
||||
Some("f") => HfPages::First,
|
||||
Some("l") => HfPages::Even,
|
||||
_ => HfPages::Default,
|
||||
};
|
||||
self.start_header_footer(HeaderFooter { footer, pages });
|
||||
return;
|
||||
}
|
||||
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
|
||||
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
|
||||
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
|
||||
@@ -962,9 +1218,16 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
Dest::FontTbl => {
|
||||
if w == "f" {
|
||||
self.font_cur = pv;
|
||||
self.font_name.clear();
|
||||
match w {
|
||||
"f" => {
|
||||
self.font_cur = pv;
|
||||
self.font_name.clear();
|
||||
self.font_cp_cur = None;
|
||||
}
|
||||
"fcharset" => self.font_cp_cur = wordcraft_doc::encoding::charset_code_page(pv),
|
||||
// A font's explicit code page (`\cpg1251`) wins over its charset.
|
||||
"cpg" if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) => self.font_cp_cur = Some(pv as u16),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1115,7 +1378,14 @@ impl Reader {
|
||||
self.st.fmt.sub = false;
|
||||
}
|
||||
"fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None },
|
||||
"ansicpg" => {
|
||||
if pv > 0 && wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&(pv as u16)) {
|
||||
self.ansicpg = pv as u16;
|
||||
}
|
||||
}
|
||||
"deff" => self.deff = pv,
|
||||
"f" => {
|
||||
self.st.cp = self.font_cps.get(&pv).copied();
|
||||
let name = self.fonts.get(&pv).cloned();
|
||||
match name {
|
||||
Some(n) if model::is_mono(&n) => {
|
||||
@@ -1142,7 +1412,12 @@ impl Reader {
|
||||
"intbl" => self.st.intbl = true,
|
||||
"ls" => self.st.ls = Some(pv),
|
||||
"ilvl" => self.st.ilvl = pv.clamp(0, 8) as u8,
|
||||
"brdrb" => self.rule_pending = true,
|
||||
"brdrb" | "brdrt" | "brdrl" | "brdrr" if self.cell_side.is_none() => {
|
||||
if w == "brdrb" {
|
||||
self.rule_pending = true;
|
||||
}
|
||||
self.st.border_side = w.chars().last();
|
||||
}
|
||||
"trowd" => {
|
||||
self.defs.clear();
|
||||
self.def_cur = CellDef::default();
|
||||
@@ -1154,7 +1429,63 @@ impl Reader {
|
||||
"clmgf" => self.def_cur.hmerge = 1,
|
||||
"clmrg" => self.def_cur.hmerge = 2,
|
||||
"clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(),
|
||||
"sb" => self.st.sb = Some(pv),
|
||||
"sa" => self.st.sa = Some(pv),
|
||||
"sl" => self.st.sl = pv,
|
||||
"slmult" => self.st.slmult = on,
|
||||
"li" | "lin" => self.st.li = Some(pv),
|
||||
"ri" | "rin" => self.st.ri = Some(pv),
|
||||
"fi" => self.st.fi = Some(pv),
|
||||
"clbrdrt" => self.cell_side = Some('t'),
|
||||
"clbrdrl" => self.cell_side = Some('l'),
|
||||
"clbrdrb" => self.cell_side = Some('b'),
|
||||
"clbrdrr" => self.cell_side = Some('r'),
|
||||
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
|
||||
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
|
||||
| "brdrtbl"
|
||||
if self.cell_side.is_some() || self.st.border_side.is_some() =>
|
||||
{
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let style = match w {
|
||||
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
|
||||
"brdrth" => BorderStyle::Thick,
|
||||
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
|
||||
"brdrdot" => BorderStyle::Dotted,
|
||||
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
|
||||
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
|
||||
"brdrtriple" => BorderStyle::Triple,
|
||||
"brdrwavy" => BorderStyle::Wave,
|
||||
_ => BorderStyle::Single,
|
||||
};
|
||||
let b = (style != BorderStyle::None).then_some(Border { style, ..Border::single(0.5) });
|
||||
if let Some(slot) = self.cell_border_slot() {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
"brdrw" if self.cell_side.is_some() || self.st.border_side.is_some() => {
|
||||
let width = (pv as f32 / 20.0).clamp(0.25, 6.0);
|
||||
if let Some(Some(b)) = self.cell_border_slot() {
|
||||
b.width = width;
|
||||
}
|
||||
}
|
||||
"brdrcf" if self.cell_side.is_some() || self.st.border_side.is_some() => {
|
||||
let color = self.colors.get(pv.max(0) as usize).copied().flatten();
|
||||
if let Some(Some(b)) = self.cell_border_slot() {
|
||||
b.color = color;
|
||||
}
|
||||
}
|
||||
"titlepg" => self.title_page = on,
|
||||
"paperw" | "pgwsxn" => self.page.w = Some(pv),
|
||||
"paperh" | "pghsxn" => self.page.h = Some(pv),
|
||||
"margl" | "marglsxn" => self.page.left = Some(pv),
|
||||
"margr" | "margrsxn" => self.page.right = Some(pv),
|
||||
"margt" | "margtsxn" => self.page.top = Some(pv),
|
||||
"margb" | "margbsxn" => self.page.bottom = Some(pv),
|
||||
"headery" => self.page.header = Some(pv),
|
||||
"footery" => self.page.footer = Some(pv),
|
||||
"landscape" | "lndscpsxn" => self.page.landscape = on,
|
||||
"cellx" => {
|
||||
self.cell_side = None;
|
||||
let mut d = std::mem::take(&mut self.def_cur);
|
||||
d.right = pv;
|
||||
if self.defs.len() < 63 {
|
||||
@@ -1206,6 +1537,25 @@ fn hyperlink(instr: &str) -> Option<String> {
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
parse_with_page(bytes).map(|(f, _, _)| f)
|
||||
}
|
||||
|
||||
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
||||
/// is the font of all text that doesn't select one.
|
||||
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
||||
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
||||
}
|
||||
|
||||
/// Everything an import takes from an RTF file.
|
||||
struct Parsed {
|
||||
flow: Flow,
|
||||
page: PageGeom,
|
||||
default_font: Option<String>,
|
||||
headers_footers: Vec<(HeaderFooter, Vec<FBlock>)>,
|
||||
title_page: bool,
|
||||
}
|
||||
|
||||
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
||||
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
||||
@@ -1300,7 +1650,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
if r.st.dest == Dest::Pict {
|
||||
r.text(&format!("{v:02x}"));
|
||||
} else {
|
||||
r.ch(cp1252(v));
|
||||
let c = r.byte_char(v);
|
||||
r.ch(c);
|
||||
}
|
||||
i += 4;
|
||||
}
|
||||
@@ -1350,7 +1701,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
}
|
||||
} else {
|
||||
for b in chunk {
|
||||
r.ch(cp1252(*b));
|
||||
let c = r.byte_char(*b);
|
||||
r.ch(c);
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
@@ -1358,6 +1710,8 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A header or footer left open by a truncated file ends here.
|
||||
r.end_header_footer();
|
||||
if r.para_has || !r.para.inlines.is_empty() {
|
||||
r.end_para(false);
|
||||
}
|
||||
@@ -1371,12 +1725,72 @@ pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
Ok(Flow { blocks, meta: r.meta })
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned();
|
||||
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page })
|
||||
}
|
||||
|
||||
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
||||
/// column, and each cell spans the grid columns between its left and right edges, as Word lays
|
||||
/// out RTF rows whose cells don't line up. Returns the widths in points, or none (equal columns)
|
||||
/// when the edges don't make a grid.
|
||||
fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
|
||||
let mut edges: Vec<i32> = rights.iter().flatten().copied().filter(|&x| x > 0).collect();
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
let ok = rights.len() == rows.len()
|
||||
&& rows
|
||||
.iter()
|
||||
.zip(rights)
|
||||
.all(|(r, x)| r.len() == x.len() && x.windows(2).all(|w| w.first() < w.get(1)) && x.first().is_some_and(|&v| v > 0))
|
||||
&& !edges.is_empty()
|
||||
&& edges.len() <= 63;
|
||||
if !ok {
|
||||
return Vec::new();
|
||||
}
|
||||
for (row, x) in rows.iter_mut().zip(rights) {
|
||||
let mut left = 0;
|
||||
for (c, &right) in row.iter_mut().zip(x) {
|
||||
let span = edges.iter().filter(|&&e| e > left && e <= right).count().max(1);
|
||||
c.colspan = span as u32;
|
||||
left = right;
|
||||
}
|
||||
}
|
||||
let mut prev = 0;
|
||||
edges
|
||||
.iter()
|
||||
.map(|&e| {
|
||||
let w = (e - prev) as f32 / 20.0;
|
||||
prev = e;
|
||||
w
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse RTF bytes into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
Ok(model::to_doc(&parse(bytes)?))
|
||||
let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?;
|
||||
let mut doc = model::to_doc_in(&flow, page.section());
|
||||
for (which, blocks) in &headers_footers {
|
||||
let blocks = model::part_blocks(&mut doc, blocks);
|
||||
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
|
||||
let set = if which.footer { &mut doc.last_section.footers } else { &mut doc.last_section.headers };
|
||||
match which.pages {
|
||||
HfPages::Default => set.default = Some(id),
|
||||
HfPages::First => set.first = Some(id),
|
||||
HfPages::Even => set.even = Some(id),
|
||||
}
|
||||
}
|
||||
doc.last_section.title_page = title_page;
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1420,6 +1834,193 @@ mod tests {
|
||||
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_borders() {
|
||||
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
|
||||
let rtf = br"{\rtf1\ansi{\colortbl;\red0\green0\blue0;\red216\green216\blue216;}
|
||||
\pard\brdrb\brdrs\brdrw10\brdrcf2 Section\par
|
||||
\pard Body\par
|
||||
\pard\brdrt\brdrdb\brdrl\brdrs\brdrr\brdrs Boxed\par
|
||||
\pard\brdrb\brdrs\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let paras: Vec<&Para> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||||
let b = paras[0].borders.unwrap();
|
||||
let bottom = b.bottom.unwrap();
|
||||
assert_eq!((bottom.width, bottom.color), (0.5, Some(Rgb(216, 216, 216))));
|
||||
assert!(b.top.is_none());
|
||||
assert_eq!(paras[0].kind, Kind::Normal);
|
||||
// \pard resets them.
|
||||
assert!(paras[1].borders.is_none());
|
||||
let b = paras[2].borders.unwrap();
|
||||
assert_eq!(b.top.map(|x| x.style), Some(wordcraft_doc::props::BorderStyle::Double));
|
||||
assert!(b.left.is_some() && b.right.is_some() && b.bottom.is_none());
|
||||
// An empty paragraph with only a bottom border is still a rule.
|
||||
assert_eq!(paras[3].kind, Kind::Rule);
|
||||
// The document's paragraph carries them.
|
||||
let d = import(rtf).unwrap();
|
||||
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
|
||||
assert!(first.props.borders.and_then(|b| b.bottom).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_footers() {
|
||||
let rtf = br"{\rtf1\ansi\titlepg
|
||||
{\header \pard\qr Running head\par}
|
||||
{\headerf \pard First head {\b bold}\par}
|
||||
{\footer \pard Page \chpgn\par}
|
||||
\pard Body text\par
|
||||
\trowd\cellx2000\pard\intbl cell\cell\row
|
||||
\pard after\par}";
|
||||
let d = import(rtf).unwrap();
|
||||
let s = &d.last_section;
|
||||
assert!(s.title_page);
|
||||
let text = |id: Option<u32>| {
|
||||
let p = id.and_then(|id| d.parts.get(&id)).unwrap();
|
||||
p.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
};
|
||||
assert_eq!(text(s.headers.default), "Running head");
|
||||
assert_eq!(text(s.headers.first), "First head bold");
|
||||
assert!(text(s.footers.default).starts_with("Page"));
|
||||
assert!(s.footers.first.is_none() && s.headers.even.is_none());
|
||||
// The body keeps only its own text, and its table survives the header groups.
|
||||
let f = parse(rtf).unwrap();
|
||||
let t = texts(&f);
|
||||
assert_eq!(t, ["Normal:Body text", "table1x1:cell", "Normal:after"]);
|
||||
// Without \titlepg there is no separate first page.
|
||||
let d = import(br"{\rtf1\ansi{\headerf x\par} y\par}").unwrap();
|
||||
assert!(!d.last_section.title_page && d.last_section.headers.first.is_some());
|
||||
// An unterminated header still ends, and the body text after it isn't lost.
|
||||
assert!(import(br"{\rtf1\ansi{\header x").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spacing_and_indents() {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\pard\sb120\sa150\sl360\slmult1\li720\ri360\fi-360 a\par
|
||||
\pard\sl-280 b\par
|
||||
\pard\sl300\slmult0 c\par
|
||||
\pard d\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let sp: Vec<model::Spacing> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.spacing) } else { None }).collect();
|
||||
assert_eq!(sp[0].before, Some(6.0));
|
||||
assert_eq!(sp[0].after, Some(7.5));
|
||||
assert_eq!(sp[0].line, Some(LineSpacing::Multiple(1.5)));
|
||||
assert_eq!((sp[0].left, sp[0].right, sp[0].first), (Some(36.0), Some(18.0), Some(-18.0)));
|
||||
assert_eq!(sp[1].line, Some(LineSpacing::Exactly(14.0)));
|
||||
assert_eq!(sp[2].line, Some(LineSpacing::AtLeast(15.0)));
|
||||
// \pard resets them.
|
||||
assert_eq!(sp[3], model::Spacing::default());
|
||||
// The document's defaults are RTF's: single, no space after; the paragraph's own win.
|
||||
let d = import(rtf).unwrap();
|
||||
assert_eq!(d.styles.default_para.space_after, Some(0.0));
|
||||
assert_eq!(d.styles.default_para.line_spacing, Some(LineSpacing::Multiple(1.0)));
|
||||
let first = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p) } else { None }).unwrap();
|
||||
assert_eq!(first.props.space_after, Some(7.5));
|
||||
assert_eq!(first.props.indent_first, Some(-18.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_font_is_deff_or_font_zero() {
|
||||
// Text that never selects a font is in the default font: \deff, else font 0.
|
||||
let d = import(br"{\rtf1\ansi{\fonttbl{\f0\fnil Times New Roman Cyr;}{\f1 Arial;}}\plain x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Times New Roman Cyr"));
|
||||
let d = import(br"{\rtf1\ansi\deff1{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}} x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Arial"));
|
||||
// No font table: our default stays.
|
||||
let d = import(br"{\rtf1\ansi x\par}").unwrap();
|
||||
assert_eq!(d.styles.default_chr.font, wordcraft_doc::StyleSheet::builtin().default_chr.font);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_and_margins() {
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\margl1134\margr850\margt1134\margb1134 x\par}").unwrap();
|
||||
let s = &d.last_section;
|
||||
assert!((s.page_w - 595.3).abs() < 0.1 && (s.page_h - 841.9).abs() < 0.1, "{} x {}", s.page_w, s.page_h);
|
||||
assert!((s.margin_left - 56.7).abs() < 0.1 && (s.margin_right - 42.5).abs() < 0.1);
|
||||
assert!(!s.landscape);
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\paperh16838\landscape x\par}").unwrap();
|
||||
assert!(d.last_section.landscape && d.last_section.page_w > d.last_section.page_h);
|
||||
// Nonsense keeps Word's defaults.
|
||||
let d = import(br"{\rtf1\ansi\paperw0\margl99999 x\par}").unwrap();
|
||||
assert_eq!(d.last_section, wordcraft_doc::SectionProps::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_grid_from_cell_edges_and_borders() {
|
||||
// Rows whose cells don't line up share one grid: edges 2000, 3000, 9000.
|
||||
let rtf = br"{\rtf1\ansi
|
||||
\trowd\cellx2000\cellx9000 \pard\intbl a\cell b\cell\row
|
||||
\trowd\cellx3000\cellx9000 \pard\intbl c\cell d\cell\row
|
||||
\pard after\par}";
|
||||
let f = parse(rtf).unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(t.widths, [100.0, 50.0, 300.0]);
|
||||
assert_eq!(t.rows[0].iter().map(|c| c.colspan).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(t.rows[1].iter().map(|c| c.colspan).collect::<Vec<_>>(), [2, 1]);
|
||||
assert_eq!(t.cols(), 3);
|
||||
// RTF tables have no grid style: each cell draws the borders it names, nothing else.
|
||||
assert!(t.borderless);
|
||||
assert!(t.rows.iter().flatten().all(|c| c.borders.is_none()));
|
||||
// A rule under a cell (a letterhead's line): the bottom side only, its width and colour.
|
||||
let f =
|
||||
parse(br"{\rtf1\ansi{\colortbl;\red174\green174\blue174;}\trowd\clbrdrb\brdrs\brdrw15\brdrcf1\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
let b = t.rows[0][0].borders.unwrap();
|
||||
assert!(b.top.is_none() && b.left.is_none() && b.right.is_none());
|
||||
let bottom = b.bottom.unwrap();
|
||||
assert_eq!((bottom.style, bottom.width, bottom.color), (wordcraft_doc::props::BorderStyle::Single, 0.75, Some(Rgb(174, 174, 174))));
|
||||
// Every side, in different styles, per cell.
|
||||
let f = parse(br"{\rtf1\ansi\trowd\clbrdrt\brdrdb\clbrdrl\brdrs\brdrw10\clbrdrb\brdrdot\clbrdrr\brdrth\cellx2000\cellx4000\pard\intbl a\cell b\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
let b = t.rows[0][0].borders.unwrap();
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
assert_eq!(
|
||||
[b.top.map(|x| x.style), b.left.map(|x| x.style), b.bottom.map(|x| x.style), b.right.map(|x| x.style)],
|
||||
[Some(S::Double), Some(S::Single), Some(S::Dotted), Some(S::Thick)]
|
||||
);
|
||||
assert_eq!(b.left.map(|x| x.width), Some(0.5));
|
||||
assert!(t.rows[0][1].borders.is_none(), "the second cell names no borders");
|
||||
// A border side set to none isn't a border.
|
||||
let f = parse(br"{\rtf1\ansi\trowd\clbrdrl\brdrnone\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert!(t.rows[0][0].borders.is_none());
|
||||
// The document's cells carry them.
|
||||
let d = import(br"{\rtf1\ansi\trowd\clbrdrb\brdrs\cellx2000\pard\intbl a\cell\row}").unwrap();
|
||||
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
|
||||
assert!(t.props.style.is_none());
|
||||
assert!(t.rows[0].cells[0].props.borders.and_then(|b| b.bottom).is_some());
|
||||
// The table sizes to the document's page, not Letter.
|
||||
let d = import(br"{\rtf1\ansi\paperw11906\margl1134\margr1134\trowd\cellx4818\cellx9638\pard\intbl a\cell b\cell\row}").unwrap();
|
||||
let t = d.body.iter().find_map(|b| if let wordcraft_doc::Block::Table(t) = &**b { Some(t) } else { None }).unwrap();
|
||||
assert_eq!(t.grid, [240.9, 241.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_pages_from_ansicpg_and_font_charsets() {
|
||||
// The document's code page: Russian RTF as accounting systems write it.
|
||||
let f = parse(br"{\rtf1\ansi\ansicpg1251\deff0{\fonttbl{\f0 Times New Roman;}}\f0 \'cf\'f0\'e8\'e2\'e5\'f2, \'b8\'eb\'ea\'e0 \'b9 5\par}")
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Привет, ёлка № 5"]);
|
||||
// Font charsets switch inside a Windows-1252 document: Western, Cyrillic, Central
|
||||
// European, then back to the first font.
|
||||
let f = parse(
|
||||
br"{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0 Arial;}{\f1\fcharset204 Arial CYR;}{\f2\fcharset238 Arial CE;}}\f0 caf\'e9 {\f1 \'c4\'e0} \f2 \'a3\'f3d\'9f \f0 na\'efve\par}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:café Да Łódź naïve"]);
|
||||
// The default font's charset applies before any \f; an explicit \cpg wins over \fcharset.
|
||||
let f = parse(br"{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Times;}{\f2\fcharset0\cpg1253 Greek;}}\'cc\'e8\'f0 \f2 \'c1\'e2\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Мир Αβ"]);
|
||||
// Raw 8-bit text (not escaped) follows the same code page.
|
||||
let f = parse(b"{\\rtf1\\ansi\\ansicpg1251 \xc4\xee\xec\\par}").unwrap();
|
||||
assert_eq!(texts(&f), ["Normal:Дом"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shppict_imports_one_image_and_skips_legacy_duplicate() -> Result<(), String> {
|
||||
let pixels = image::RgbaImage::from_pixel(1, 1, image::Rgba([40, 80, 160, 255]));
|
||||
|
||||
@@ -194,6 +194,21 @@ fn numbered_list_after_bullets_keeps_its_kind() {
|
||||
assert!(md.contains("1. num one") && md.contains("2. num two"), "{md}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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)];
|
||||
let bytes = export("md", &d).unwrap().unwrap();
|
||||
let blocks = flat(&import("md", &bytes).unwrap().unwrap());
|
||||
let [FBlock::Para(p)] = blocks.as_slice() else { panic!("{text}: {}", String::from_utf8_lossy(&bytes)) };
|
||||
assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0 })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_round_trip() {
|
||||
check_round_trip("md", true);
|
||||
@@ -280,6 +295,14 @@ fn txt_round_trip() {
|
||||
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_export_writes_symbol_font_text_as_unicode() {
|
||||
let mut d = Document::from_text("Done J\u{F0FC}");
|
||||
d.format_range(&wordcraft_doc::Pos::body(0, 5), &wordcraft_doc::Pos::body(0, 9), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
let out = export("txt", &d).unwrap().unwrap();
|
||||
assert_eq!(String::from_utf8(out).unwrap(), "Done \u{263A}\u{2713}");
|
||||
}
|
||||
|
||||
/// #569: a file with an extension we don't know opens as text when it is text (a playlist), and
|
||||
/// a binary file is told apart.
|
||||
#[test]
|
||||
@@ -654,3 +677,16 @@ fn exported_links_keep_only_safe_targets() {
|
||||
assert!(!md.contains(&format!("[{text}](")), "md: {md}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_drawings_are_left_out_of_exports() {
|
||||
let mut d = wordcraft_doc::Document::from_text("x");
|
||||
let key = d.add_media(vec![0x89, b'P', b'N', b'G'], "png");
|
||||
for hidden in [false, true] {
|
||||
let float = wordcraft_doc::para::Float { hidden, ..Default::default() };
|
||||
let image = wordcraft_doc::InlineObject::Image { media: key.clone(), w: 10.0, h: 10.0, alt: "Logo".into(), float, crop: [0.0; 4], ole: None };
|
||||
d.insert_object(&wordcraft_doc::Pos::body(0, 0), image, &Default::default()).unwrap();
|
||||
}
|
||||
// Two pictures, one hidden: one comes out.
|
||||
assert_eq!(crate::html::export(&d).matches("<img").count(), 1);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ fn blocks_text(blocks: &[FBlock], out: &mut Vec<String>, lists: &mut ListCounter
|
||||
}
|
||||
None => lists.reset(),
|
||||
}
|
||||
line.push_str(&p.text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
|
||||
line.push_str(&p.shown_text().replace('\n', "\r\n").replace(['\u{000C}', '\u{000E}'], ""));
|
||||
out.push(line);
|
||||
}
|
||||
FBlock::Table(t) => {
|
||||
|
||||
@@ -390,6 +390,23 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<D
|
||||
out
|
||||
}
|
||||
|
||||
/// One object drawn alone, as its page draws it: the picture, shape or group `o` in `rect` (its
|
||||
/// unturned frame) and the text of the text box story `text_box` on `page`. A dragged object's
|
||||
/// preview, which mustn't show what was behind it (#1010).
|
||||
pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect, text_box: Option<u32>, opts: &DisplayOptions) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
object_draws(o, rect, Spin::default(), 1.0, opts, &mut out);
|
||||
let Some(id) = text_box else { return out };
|
||||
for items in [&page.items, &page.header, &page.footer] {
|
||||
let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id)).collect();
|
||||
match warped_boxes(doc, items).into_iter().find(|(w, ..)| *w == id) {
|
||||
Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, 1.0, &mut out),
|
||||
_ => lines.into_iter().for_each(|it| item(doc, it, opts, 1.0, &mut out)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their
|
||||
/// story, frame and warp. Empty, without a walk of the document, when none is.
|
||||
fn warped_boxes(doc: &Document, items: &[Placed]) -> Vec<(u32, Rect, wordcraft_doc::wordart::TextWarp)> {
|
||||
@@ -720,7 +737,13 @@ fn lines(
|
||||
text.push_str(s);
|
||||
shown = Some(r);
|
||||
} else if let Some(p) = para {
|
||||
text.push_str(p.text.get(c.start..c.end).unwrap_or(""));
|
||||
let t = p.text.get(c.start..c.end).unwrap_or("");
|
||||
// Symbol-font characters (Wingdings…) read as the Unicode symbols drawn.
|
||||
if wordcraft_doc::symbol_fonts::is_symbol_font(&st.rc.font) {
|
||||
text.push_str(&wordcraft_doc::symbol_fonts::map_text(&st.rc.font, t));
|
||||
} else {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
let b = text.len();
|
||||
let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]);
|
||||
@@ -1453,6 +1476,9 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di
|
||||
InlineObject::Group { .. } => {
|
||||
let mut members = Vec::new();
|
||||
for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) {
|
||||
if c.frame().is_some_and(|(_, _, f)| f.hidden) {
|
||||
continue;
|
||||
}
|
||||
object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members);
|
||||
}
|
||||
match o.canvas_style() {
|
||||
|
||||
+133
-50
@@ -660,6 +660,14 @@ impl Ctx<'_> {
|
||||
|
||||
/// The list label of `p`, advancing the list counters: call it once per paragraph, in
|
||||
/// document order (only for paragraphs that will show).
|
||||
/// Whether `p` gets a list label ([`Ctx::next_label`] is `Some`), without counting it.
|
||||
fn has_label(&self, p: &Paragraph) -> bool {
|
||||
p.props
|
||||
.numbering
|
||||
.or_else(|| self.doc.styles.resolve_para(&p.props).numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.is_some_and(|n| self.doc.numbering.shows_label(n.num, n.level))
|
||||
}
|
||||
fn next_label(&mut self, p: &Paragraph) -> Option<(String, Level)> {
|
||||
let n = p.props.numbering.or_else(|| self.doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0)?;
|
||||
self.counters.next_label(&self.doc.numbering, n.num, n.level)
|
||||
@@ -734,12 +742,13 @@ impl Ctx<'_> {
|
||||
}
|
||||
|
||||
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
|
||||
/// text unless `show_hidden`, or in a tracked deletion with `hide_deleted`. `table_chr` is the
|
||||
/// text unless `show_hidden`, in a tracked deletion with `hide_deleted`, or a hidden drawing
|
||||
/// (`Float::hidden`, whatever is shown). `table_chr` is the
|
||||
/// cell's table-style character formatting ([`para::CellText::chr`]), resolved as the paragraph is
|
||||
/// laid out with (`StyleSheet::resolve_char_in`).
|
||||
fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>, show_hidden: bool, hide_deleted: bool) -> impl Fn(usize) -> bool {
|
||||
let mut left = Vec::new();
|
||||
// Nothing can be left out with hidden text shown and markup on.
|
||||
// No text left out with hidden text shown and markup on (hidden drawings still are, below).
|
||||
if (hide_deleted || !show_hidden) && !p.objects.is_empty() {
|
||||
let style = p.props.style.as_deref();
|
||||
let is_left = |c: &CharProps| {
|
||||
@@ -766,7 +775,8 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps>
|
||||
left.push(l);
|
||||
}
|
||||
}
|
||||
move |k| left.get(k).copied().unwrap_or(false)
|
||||
// A hidden drawing is left out whatever is shown (Word shows it in no view).
|
||||
move |k| left.get(k).copied().unwrap_or(false) || p.objects.get(k).and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden)
|
||||
}
|
||||
|
||||
/// Footnote and endnote numbers (each kind counted separately), in document order, as each
|
||||
@@ -785,15 +795,16 @@ fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap<u32, usize>) ->
|
||||
let mut order = [0u32; 2];
|
||||
let mut si = 0usize;
|
||||
// Reading order, text boxes included where they are. A hidden reference mark still takes
|
||||
// its number, as in Word; a deleted one (or one in a deleted text box) does not.
|
||||
// its number, as in Word; a deleted one (or one in a deleted or hidden text box) does not,
|
||||
// and `para_notes` doesn't place its note either.
|
||||
let mut cur: Option<(usize, Vec<bool>)> = None;
|
||||
doc.objects_in_reading_order_by_block(&mut |block, p, k| {
|
||||
let key = p as *const Paragraph as usize;
|
||||
if cur.as_ref().is_none_or(|(at, _)| *at != key) {
|
||||
let deleted = left_out_objects(doc, p, None, true, hide_deleted);
|
||||
cur = Some((key, (0..p.objects.len()).map(deleted).collect()));
|
||||
let gone = left_out_objects(doc, p, None, true, hide_deleted);
|
||||
cur = Some((key, (0..p.objects.len()).map(gone).collect()));
|
||||
}
|
||||
if cur.as_ref().is_some_and(|(_, deleted)| deleted.get(k).copied().unwrap_or(false)) {
|
||||
if cur.as_ref().is_some_and(|(_, gone)| gone.get(k).copied().unwrap_or(false)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) {
|
||||
@@ -927,10 +938,9 @@ fn layout_box(
|
||||
// space after the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && i == 0 {
|
||||
before = 0.0;
|
||||
} else if i > 0 && (pl.rp.before_auto || prev_after_auto) {
|
||||
} else if i > 0 && before > 0.0 && prev_after > 0.0 {
|
||||
before = (before - prev_after).max(0.0);
|
||||
}
|
||||
// Word: space between paragraphs is before + after (no collapsing).
|
||||
y += if i == 0 { before } else { before.max(0.0) };
|
||||
// Floating objects anchored here: place them, then wrap the text around them.
|
||||
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
||||
@@ -964,7 +974,7 @@ fn layout_box(
|
||||
// Automatic space after: none after the last paragraph of a cell or box.
|
||||
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
|
||||
y += pl.height + after;
|
||||
prev_after = if same && ctxl { 0.0 } else { after };
|
||||
prev_after = after;
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
prev_listed = listed;
|
||||
prev_style = Some((pl.rp.style.clone(), ctxl));
|
||||
@@ -1915,18 +1925,28 @@ fn layout_pass(
|
||||
// one long page
|
||||
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
|
||||
// A page holds text running one way: a section turning the other way starts a page.
|
||||
let s = &doc.settings;
|
||||
let (filler, restart) = section_side(start, restart, pb.number, pb.pages.len(), s.even_odd_headers, s.mirror_margins);
|
||||
// The blank page that puts the section on the right side ends the previous section.
|
||||
if filler
|
||||
&& !web
|
||||
&& let Some((_, prev)) = si.checked_sub(1).and_then(|k| sections.get(k))
|
||||
{
|
||||
pb.sect = prev;
|
||||
pb.sect_idx = si - 1;
|
||||
let (ph, pf) = (linked(§ions, si - 1, |s| s.headers), linked(§ions, si - 1, |s| s.footers));
|
||||
let top = body_top_for(&mut ctx, prev, ph.default);
|
||||
pb.foot_top = foot_top_for(&mut ctx, prev, pf.default);
|
||||
pb.new_page(block, top);
|
||||
pb.sect = sect;
|
||||
pb.sect_idx = si;
|
||||
}
|
||||
if let Some(n) = restart {
|
||||
pb.number = n.saturating_sub(1);
|
||||
}
|
||||
pb.foot_top = first_foot;
|
||||
pb.new_page(block, first_top);
|
||||
pb.foot_top = foot_top;
|
||||
if !web && matches!(start, SectionStart::EvenPage | SectionStart::OddPage) {
|
||||
let want_even = start == SectionStart::EvenPage;
|
||||
if pb.number.is_multiple_of(2) != want_even {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continuous: new column layout below the current text (its tallest column).
|
||||
pb.cols = sect.column_boxes();
|
||||
@@ -2278,6 +2298,33 @@ fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option<f32> {
|
||||
Some(y + l.top + l.height - f.top)
|
||||
}
|
||||
|
||||
/// Whether a section that starts on a new page needs a blank page first so it lands on the right
|
||||
/// side, and the page number it restarts at. `last` is the number of the page before it and
|
||||
/// `pages` how many pages there are so far.
|
||||
/// - An odd- or even-page break counts on from the previous section's numbers. A section that
|
||||
/// restarts its numbering gets no blank page unless the document prints both sides (different
|
||||
/// odd and even headers, or mirror margins); a restart at the wrong parity takes the next number.
|
||||
/// - With different odd and even headers, a next-page break that restarts the numbers at an odd
|
||||
/// (even) number starts on an odd (even) sheet.
|
||||
///
|
||||
/// (Word's output for docxide-pdf's fixtures: bush_fires_act_comparison has a blank page 2.)
|
||||
fn section_side(start: SectionStart, restart: Option<u32>, last: u32, pages: usize, even_odd: bool, mirror: bool) -> (bool, Option<u32>) {
|
||||
match start {
|
||||
SectionStart::OddPage | SectionStart::EvenPage => {
|
||||
let want_odd = start == SectionStart::OddPage;
|
||||
let continuing_odd = last.saturating_add(1) % 2 == 1;
|
||||
let filler = pages > 0 && continuing_odd != want_odd && (restart.is_none() || even_odd || mirror);
|
||||
let restart = restart.map(|n| if (n % 2 == 1) == want_odd { n } else { n.saturating_add(1) });
|
||||
(filler, restart)
|
||||
}
|
||||
SectionStart::NextPage => {
|
||||
let filler = even_odd && pages > 0 && restart.is_some_and(|n| (n % 2 == 1) != ((pages + 1) % 2 == 1));
|
||||
(filler, restart)
|
||||
}
|
||||
SectionStart::Continuous | SectionStart::NextColumn => (false, restart),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where body text starts: the top margin, or below the header if it's taller.
|
||||
fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32 {
|
||||
let Some(id) = header else { return sect.margin_top };
|
||||
@@ -2468,7 +2515,12 @@ fn carried(rect: Rect, spin: Spin, centre: Point) -> Rect {
|
||||
}
|
||||
|
||||
fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
|
||||
o.group_rects(rect.x, rect.y, rect.w, rect.h).into_iter().map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c)).collect()
|
||||
// Hidden members aren't placed (nor their text boxes laid out).
|
||||
o.group_rects(rect.x, rect.y, rect.w, rect.h)
|
||||
.into_iter()
|
||||
.filter(|(_, c)| !c.frame().is_some_and(|(_, _, f)| f.hidden))
|
||||
.map(|([x, y, w, h], c)| (Rect::new(x, y, w, h), c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Position the floating objects anchored in `p` for a paragraph whose text starts at `y0` in
|
||||
@@ -2615,7 +2667,7 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32
|
||||
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
|
||||
for (ci, c) in pl.clusters.iter().enumerate() {
|
||||
if let para::ClKind::Object(oi) = c.kind
|
||||
&& let Some(o) = p.objects.get(oi)
|
||||
&& let Some(o) = p.objects.get(oi).filter(|o| !o.frame().is_some_and(|(_, _, f)| f.hidden))
|
||||
{
|
||||
for id in o.text_boxes() {
|
||||
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
|
||||
@@ -2662,7 +2714,6 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
|
||||
let mut before = pl.rp.space_before;
|
||||
let prev_para = pb.prev.clone();
|
||||
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
|
||||
@@ -2679,42 +2730,39 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
// off the space before here (Word: 24 pt before after 10 pt after leaves 14 pt).
|
||||
before = (before - after.max(0.0)).max(0.0);
|
||||
}
|
||||
let mut prev = pb.prev.take();
|
||||
if let Some((style, ctxl, after, _, _)) = &mut prev
|
||||
&& *style == pl.rp.style
|
||||
if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take()
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if *ctxl {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
// Contextual spacing: no space between paragraphs of the same style when either asks for it.
|
||||
if style == pl.rp.style {
|
||||
if ctxl {
|
||||
pb.y -= after;
|
||||
after = 0.0;
|
||||
}
|
||||
if pl.rp.contextual_spacing {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
if pl.rp.contextual_spacing {
|
||||
before = 0.0;
|
||||
// Automatic spacing: none between two list items (as in an HTML list; measured in Word:
|
||||
// any two items, of one list or two, at any level).
|
||||
if listed && label.is_some() {
|
||||
if after_auto {
|
||||
pb.y -= after;
|
||||
after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// The space between two paragraphs is the larger of the first's space after and the
|
||||
// second's space before, not their sum.
|
||||
if before > 0.0 && after > 0.0 {
|
||||
before = (before - after).max(0.0);
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none between two list items (as in an HTML list; measured in Word: any
|
||||
// two items, of one list or two, at any level).
|
||||
if let Some((_, _, after, after_auto, true)) = &mut prev
|
||||
&& label.is_some()
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if *after_auto {
|
||||
pb.y -= *after;
|
||||
*after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of the document, and it collapses with the space after
|
||||
// the paragraph above (the larger one counts).
|
||||
// Automatic spacing: none at the top of the document.
|
||||
if pl.rp.before_auto && block == 0 {
|
||||
before = 0.0;
|
||||
} else if let Some((_, _, after, after_auto, _)) = prev
|
||||
&& (pl.rp.before_auto || after_auto)
|
||||
&& !pb.at_top()
|
||||
{
|
||||
before = (before - after).max(0.0);
|
||||
}
|
||||
pb.y += before;
|
||||
let mut applied_before = before;
|
||||
@@ -2737,6 +2785,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
anchor_y = y0 - applied_before;
|
||||
}
|
||||
let mut n = pl.lines.len();
|
||||
// Worked out only for a last line on a page with notes.
|
||||
let mut after_on_page: Option<f32> = None;
|
||||
let mut l0 = 0;
|
||||
while l0 < n {
|
||||
// How many lines fit?
|
||||
@@ -2775,6 +2825,17 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
- if pb.notes.is_empty() && !new_notes.is_empty() { pb.seps.sep_h(false) } else { 0.0 }
|
||||
- extra;
|
||||
let bottom = pb.y + lead + (l.top + l.height - first.top);
|
||||
// On a page with footnotes the paragraph's last line keeps its space after clear of
|
||||
// the separator (Word): if the space doesn't fit, the line goes to the next page (and,
|
||||
// with widow control, one more with it). Without notes the space after may run past
|
||||
// the bottom margin as usual.
|
||||
let has_notes = !pb.notes.is_empty() || !new_notes.is_empty() || !line_notes.is_empty();
|
||||
let tail = if has_notes && l1 + 1 == n {
|
||||
*after_on_page.get_or_insert_with(|| space_after_kept(ctx, block, &pl.rp, label.is_some()))
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let over = bottom + tail > limit + 0.01;
|
||||
// Notes too long for the rest of the page continue on the next one: the line still
|
||||
// fits if the notes before its last one, and that one's first line, fit below it.
|
||||
// The notes then take the rest of the page.
|
||||
@@ -2784,7 +2845,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let sep = pb.seps.sep_h(pb.notes.first().is_some_and(|n| n.cont));
|
||||
pb.body_floor().max(bottom) + sep + before + last.first_piece() + pb.seps.notice_h() <= pb.orig_bottom + 0.01
|
||||
});
|
||||
if bottom > limit + 0.01 && l1 > l0 && !split {
|
||||
if over && l1 > l0 && !split {
|
||||
break;
|
||||
}
|
||||
// A line holding nothing but a page or column break stays at the bottom of the page
|
||||
@@ -2794,7 +2855,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
&& p.text
|
||||
.get(l.start..l.stop)
|
||||
.is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK)));
|
||||
if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break {
|
||||
if over && l1 == l0 && !pb.at_top() && !split && !only_break {
|
||||
break;
|
||||
}
|
||||
new_notes.extend(line_notes);
|
||||
@@ -3023,6 +3084,28 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo
|
||||
right - left
|
||||
}
|
||||
|
||||
/// The space after body paragraph `block` (resolved as `rp`) that is really left below it: none
|
||||
/// when contextual spacing or automatic spacing between list items drops it before the next
|
||||
/// paragraph. Same tests as the spacing between paragraphs in `place_para_in_flow`: contextual
|
||||
/// spacing removes this paragraph's space after only when this one asks for it, and the next
|
||||
/// counts as a list item when it gets a label.
|
||||
fn space_after_kept(ctx: &Ctx, block: usize, rp: &wordcraft_doc::resolve::ResolvedPara, listed: bool) -> f32 {
|
||||
let after = rp.space_after.max(0.0);
|
||||
if after <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if let Some(Block::Para(next)) = ctx.doc.body.get(block + 1).map(|b| &**b) {
|
||||
let nrp = ctx.doc.styles.resolve_para(&next.props);
|
||||
if nrp.style == rp.style && rp.contextual_spacing {
|
||||
return 0.0;
|
||||
}
|
||||
if listed && rp.after_auto && ctx.has_label(next) {
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
after
|
||||
}
|
||||
|
||||
/// Height of the first line of the block after `block` (for keep-with-next).
|
||||
/// The room the paragraphs after `block` need on its page for keep with next: the next one's
|
||||
/// first line, or all of it and what it keeps with in turn when it is kept with the next too
|
||||
|
||||
+78
-20
@@ -15,6 +15,53 @@ use wordcraft_fonts::FaceRef;
|
||||
use crate::NumeralMode;
|
||||
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text};
|
||||
|
||||
/// How a run set in one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings)
|
||||
/// shows its characters. Those fonts are proprietary and usually missing: a substitute has other
|
||||
/// glyphs, or none, at their codes, so each character is drawn as its Unicode equivalent
|
||||
/// ([`wordcraft_doc::symbol_fonts`]) from a face that has it. The text itself is unchanged.
|
||||
#[derive(Clone, Copy)]
|
||||
struct SymbolRun<'a> {
|
||||
family: &'a str,
|
||||
/// The symbol font itself is installed (`face` is it).
|
||||
installed: bool,
|
||||
face: FaceRef,
|
||||
}
|
||||
|
||||
impl<'a> SymbolRun<'a> {
|
||||
fn of(family: &'a str, r: &wordcraft_fonts::word::Resolved) -> Option<Self> {
|
||||
wordcraft_doc::symbol_fonts::is_symbol_font(family).then_some(SymbolRun { family, installed: !r.substituted, face: r.face })
|
||||
}
|
||||
|
||||
/// The character drawn for `c`.
|
||||
fn show(&self, c: char) -> char {
|
||||
if self.installed {
|
||||
if self.face.covers(c) {
|
||||
return c;
|
||||
}
|
||||
// A plain 8-bit code in the installed font: its glyph sits at the private-use code.
|
||||
if let Some(p) = (0x20..=0xFF).contains(&u32::from(c)).then(|| char::from_u32(0xF000 + u32::from(c))).flatten()
|
||||
&& self.face.covers(p)
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
let Some(u) = wordcraft_doc::symbol_fonts::symbol_char(self.family, c) else { return c };
|
||||
// A few list bullets have common look-alikes in far more fonts: use one when no face
|
||||
// has the exact character.
|
||||
let alt = match u {
|
||||
'\u{26AB}' => Some('\u{25CF}'),
|
||||
'\u{25FC}' => Some('\u{25A0}'),
|
||||
'\u{2B9A}' => Some('\u{27A2}'),
|
||||
_ => None,
|
||||
};
|
||||
let has = |ch: char| self.face.covers(ch) || wordcraft_fonts::FontDb::global().fallback_for(ch, self.face.id()).is_some();
|
||||
match alt {
|
||||
Some(a) if !has(u) && has(a) => a,
|
||||
_ => u,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face at a size with the formatting needed to draw it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StyleRun {
|
||||
@@ -657,7 +704,11 @@ impl<'a> Builder<'a> {
|
||||
// Complex-script characters (Persian, Arabic…) use the run's complex-script font, size,
|
||||
// bold and italic.
|
||||
let rc_cs: Option<Arc<ResolvedChar>> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex()));
|
||||
let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face;
|
||||
let resolved = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let primary = resolved.face;
|
||||
// Symbol-font characters are laid out as their Unicode equivalents.
|
||||
let sym = SymbolRun::of(&rc.font, &resolved);
|
||||
let show = |c: char| sym.map_or(c, |m| m.show(c));
|
||||
let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face);
|
||||
// Chinese, Japanese and Korean characters use the run's East Asian font (`w:eastAsia`) when
|
||||
// it is installed and has them.
|
||||
@@ -692,6 +743,8 @@ impl<'a> Builder<'a> {
|
||||
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
|
||||
for (i, c) in text.char_indices() {
|
||||
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
|
||||
// A symbol-font character becomes its Unicode symbol (#776).
|
||||
let c = show(c);
|
||||
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
|
||||
// fallback face are picked for the displayed char, so Hindi digits land in an
|
||||
// Arabic-capable face and Western digits in a Latin one.
|
||||
@@ -759,7 +812,16 @@ impl<'a> Builder<'a> {
|
||||
orig_of.push(sub.len());
|
||||
}
|
||||
let shape_text = if substituted { mapped.as_str() } else { sub };
|
||||
let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||||
let map = |c: char| {
|
||||
let d = show(c);
|
||||
if d != c {
|
||||
d
|
||||
} else if upper {
|
||||
c.to_uppercase().next().unwrap_or(c)
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
|
||||
let features: &[wordcraft_fonts::Feature] = if rc.kerned() { &[] } else { &no_kern };
|
||||
let shaped = if use_levels {
|
||||
@@ -1224,8 +1286,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
obj_index += 1;
|
||||
match p.objects.get(k) {
|
||||
Some(o) if o.is_drawing() => {
|
||||
// A hidden one takes no room, and with no height its line doesn't draw it.
|
||||
if let Some((w, h, float)) = o.frame()
|
||||
&& float.wrap == wordcraft_doc::para::Wrap::Inline
|
||||
&& !float.hidden
|
||||
{
|
||||
// A picture wider than the line keeps its size, as in Word: it runs past
|
||||
// the margin and the page edge clips it (see `ParaEnv::fit_objects`).
|
||||
@@ -1379,24 +1443,16 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let label = env.label.as_ref().map(|(text, lvl)| {
|
||||
let lc = p.mark.clone().overlaid(&lvl.chr);
|
||||
let rc = resolve(&lc);
|
||||
// A Symbol/Wingdings bullet (U+F0B7…) without that font installed, or in a face that has
|
||||
// no glyph at the private-use code: draw the standard character instead, since a
|
||||
// substitute has another glyph, or none, there.
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
let text: String = text
|
||||
.chars()
|
||||
.map(|c| match wordcraft_fonts::word::symbol_font_char(&rc.font, c) {
|
||||
Some(u) if r.substituted || !r.face.covers(c) => u,
|
||||
_ => c,
|
||||
})
|
||||
.collect();
|
||||
// A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font
|
||||
// isn't installed (shaping maps it); the label's text reads as that character too.
|
||||
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
|
||||
let before_c = b.clusters.len();
|
||||
let before_g = b.glyphs.len();
|
||||
b.shape_with(&text, 0, &rc, None, false);
|
||||
b.shape_with(text, 0, &rc, None, false);
|
||||
let mut cl: Vec<Cluster> = b.clusters.drain(before_c..).collect();
|
||||
// A right-to-left paragraph's label reads right to left too ("1." shows as ".1").
|
||||
if rp.bidi {
|
||||
let lv = bidi_levels(&text, true);
|
||||
let lv = bidi_levels(text, true);
|
||||
let cl_lv: Vec<u8> = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect();
|
||||
let order = visual_order(&cl_lv);
|
||||
cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect();
|
||||
@@ -1411,18 +1467,18 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
|
||||
glyphs.push(Glyph { dx: g.dx + x, ..*g });
|
||||
}
|
||||
let chars = text.get(c.start..c.end).unwrap_or("");
|
||||
let chars = shown(text.get(c.start..c.end).unwrap_or(""));
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == c.style => {
|
||||
r.glyphs.end = glyphs.len();
|
||||
r.text.push_str(chars);
|
||||
r.text.push_str(&chars);
|
||||
}
|
||||
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars.to_string() }),
|
||||
_ => runs.push(LabelRun { style: c.style, glyphs: g0..glyphs.len(), text: chars }),
|
||||
}
|
||||
x += c.adv;
|
||||
}
|
||||
b.glyphs.truncate(before_g);
|
||||
Label { text, style, glyphs, runs, x: 0.0, width: x }
|
||||
Label { text: shown(text), style, glyphs, runs, x: 0.0, width: x }
|
||||
});
|
||||
|
||||
let mut pl = ParaLayout {
|
||||
@@ -1674,7 +1730,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
||||
});
|
||||
let shrink = if shrink_spaces && !has_tab { space_w * MAX_SPACE_SHRINK } else { 0.0 };
|
||||
let fits = absorbs || x + c.adv <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
if !fits && j > c0 {
|
||||
// A line's first item stays on it even when it doesn't fit; markers before it
|
||||
// (a content control's start, a bookmark) take no room and don't count (Word).
|
||||
if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)) {
|
||||
// Wrap: back up to the last break opportunity on this line.
|
||||
end = LineEnd::Wrap;
|
||||
// Hyphenate only when the plain break leaves more than the hyphenation zone empty.
|
||||
|
||||
+314
-4
@@ -559,13 +559,42 @@ fn symbol_font_bullet_without_the_font_draws_a_bullet() {
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
|
||||
// Where a real Symbol font with the private-use code is installed (Windows), the code stays.
|
||||
let symbol = wordcraft_fonts::word::resolve("Symbol", false, false);
|
||||
let expected = if symbol.substituted || !symbol.face.covers('\u{F0B7}') { "\u{2022}" } else { "\u{F0B7}" };
|
||||
assert_eq!(label.text, expected);
|
||||
// The label reads as the Unicode bullet (drawn from a real Symbol font where one is installed).
|
||||
assert_eq!(label.text, "\u{2022}");
|
||||
assert_eq!(label.glyphs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_text_draws_and_reads_as_its_unicode_symbols() {
|
||||
// Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes
|
||||
// (U+F0FC = check mark, what `w:sym` writes).
|
||||
let mut d = Document::from_text("Hi J\u{F0FC} ok");
|
||||
d.format_range(&Pos::body(0, 3), &Pos::body(0, 7), &|c| c.font = Some("Wingdings".into())).unwrap();
|
||||
let l = lay(&d);
|
||||
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
|
||||
let text: String = items.iter().filter_map(|i| if let display::Draw::Glyphs { text, .. } = i { Some(text.as_str()) } else { None }).collect();
|
||||
assert_eq!(text, "Hi \u{263A}\u{2713} ok");
|
||||
// The stored text is unchanged.
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "Hi J\u{F0FC} ok");
|
||||
// Without Wingdings, each symbol is drawn from a face that has its Unicode character (never
|
||||
// the substitute's "J" or a missing glyph).
|
||||
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
|
||||
for want in ['\u{263A}', '\u{2713}'] {
|
||||
let run = items.iter().find_map(|i| match i {
|
||||
display::Draw::Glyphs { face, glyphs, text, .. } if text.contains(want) => Some((*face, glyphs.clone())),
|
||||
_ => None,
|
||||
});
|
||||
let Some((face, glyphs)) = run else { panic!("no run for {want}") };
|
||||
if face.covers(want) {
|
||||
assert!(glyphs.iter().any(|g| g.0 == face.glyph_for(want)), "{want}");
|
||||
} else {
|
||||
// No installed face has it (a bare CI machine): never the letter J's glyph.
|
||||
assert!(!glyphs.iter().any(|g| g.0 != 0 && g.0 == face.glyph_for('J')), "{want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_lay_out_cells() {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
@@ -898,6 +927,90 @@ fn section_breaks_show_with_formatting_marks() {
|
||||
assert_eq!(labels(&Document::from_text("One\nTwo"), true), (vec![], 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_between_paragraphs_is_the_larger_of_after_and_before() {
|
||||
// (space after the first, space before the second, contextual spacing on both) -> gap
|
||||
let gap = |after: f32, before: f32, contextual: bool| -> f32 {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
for (i, (a, b)) in [(after, 0.0), (0.0, before)].into_iter().enumerate() {
|
||||
let p = d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap();
|
||||
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(a), Some(b), Some(contextual));
|
||||
}
|
||||
let l = lay(&d);
|
||||
let ys: Vec<(f32, f32)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, para, .. } = i { Some((*y, para.height)) } else { None }).collect();
|
||||
ys[1].0 - (ys[0].0 + ys[0].1)
|
||||
};
|
||||
// Word's output for docxide-pdf's clean_energy_regulator_guidelines: 6pt after and 6pt before
|
||||
// leave 6pt, not 12pt.
|
||||
assert!((gap(6.0, 6.0, false) - 6.0).abs() < 0.01);
|
||||
assert!((gap(6.0, 12.0, false) - 12.0).abs() < 0.01);
|
||||
assert!((gap(12.0, 6.0, false) - 12.0).abs() < 0.01);
|
||||
assert!((gap(0.0, 6.0, false) - 6.0).abs() < 0.01);
|
||||
// Contextual spacing drops both between paragraphs of one style.
|
||||
assert!(gap(6.0, 12.0, true).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_between_paragraphs_in_a_cell_follows_the_body_rule() {
|
||||
// Three paragraphs in a cell, each 6pt before and 6pt after -> the gaps between them.
|
||||
let gaps = |contextual: bool| -> Vec<f32> {
|
||||
let mut d = Document::from_text("before\nafter");
|
||||
let mut t = Table::new(1, 1, 468.0);
|
||||
t.rows[0].cells[0].blocks = (0..3)
|
||||
.map(|k| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(&format!("p{k}"), Default::default());
|
||||
(p.props.space_after, p.props.space_before, p.props.contextual_spacing) = (Some(6.0), Some(6.0), Some(contextual));
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let mut ys: Vec<(u32, f32, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, y, para, .. } if path.0.len() == 4 => Some((path.0[3], *y, para.height)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
ys.sort_by_key(|v| v.0);
|
||||
ys.windows(2).map(|w| w[1].1 - (w[0].1 + w[0].2)).collect()
|
||||
};
|
||||
let near = |got: Vec<f32>, want: [f32; 2]| got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01);
|
||||
// The larger of after and before, not their sum.
|
||||
assert!(near(gaps(false), [6.0, 6.0]), "{:?}", gaps(false));
|
||||
// Contextual spacing drops the space between every pair, not just the first.
|
||||
assert!(near(gaps(true), [0.0, 0.0]), "{:?}", gaps(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// "One" ends the first section on page 1; the second section starts as `start`.
|
||||
let pages = |start: SectionStart, restart: Option<u32>, even_odd: bool| -> Vec<(usize, u32)> {
|
||||
let mut d = Document::from_text("One\nTwo");
|
||||
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = start;
|
||||
d.last_section.page_num_start = restart;
|
||||
d.settings.even_odd_headers = even_odd;
|
||||
lay(&d).pages.iter().map(|p| (p.section, p.number)).collect()
|
||||
};
|
||||
// An odd-page break after page 1 leaves page 2 blank; the blank page ends the first section.
|
||||
assert_eq!(pages(SectionStart::OddPage, None, false), [(0, 1), (0, 2), (1, 3)]);
|
||||
assert_eq!(pages(SectionStart::EvenPage, None, false), [(0, 1), (1, 2)]);
|
||||
// Restarting at 1 needs no blank page unless both sides print; at 2 it takes the next number.
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(1), false), [(0, 1), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(2), false), [(0, 1), (1, 3)]);
|
||||
assert_eq!(pages(SectionStart::OddPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
|
||||
// With different odd and even headers, a next-page break restarting at 1 starts on an odd
|
||||
// sheet (Word's output for docxide-pdf's bush_fires_act_comparison).
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(1), true), [(0, 1), (0, 2), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(2), true), [(0, 1), (1, 2)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, Some(1), false), [(0, 1), (1, 1)]);
|
||||
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_marks_sit_past_text_running_the_other_way() {
|
||||
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
|
||||
@@ -1228,6 +1341,56 @@ fn text_wraps_around_square_float() {
|
||||
assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_drawings_are_neither_drawn_nor_take_room() {
|
||||
// A hidden floating shape (VML `visibility:hidden`, `wp:docPr/@hidden`): no wrap area, nothing
|
||||
// drawn, as in Word for Windows. A hidden inline one takes no room in its line.
|
||||
let mut d = Document::from_text(&"Words flow around the picture here. ".repeat(30));
|
||||
let float = wordcraft_doc::para::Float {
|
||||
wrap: wordcraft_doc::para::Wrap::Square,
|
||||
h_rel: wordcraft_doc::para::Anchor::Column,
|
||||
v_rel: wordcraft_doc::para::Anchor::Paragraph,
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
};
|
||||
let inline = wordcraft_doc::para::Float { hidden: true, ..Default::default() };
|
||||
d.insert_object(&Pos::body(0, 0), picture(200.0, 200.0, inline), &Default::default()).unwrap();
|
||||
d.insert_object(&Pos::body(0, 0), picture(144.0, 100.0, float), &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let Placed::Lines { para, .. } = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() };
|
||||
let first = ¶.lines[0];
|
||||
assert!(first.left < 1.0, "the first line uses the full width: {}", first.left);
|
||||
assert!(first.baseline - first.top < 20.0, "the hidden inline shape doesn't raise the line: {first:?}");
|
||||
assert!(!l.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. })));
|
||||
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
|
||||
assert!(!draws.iter().any(|x| matches!(x, display::Draw::Shape { .. })), "nothing drawn");
|
||||
// Showing hidden text doesn't show hidden drawings.
|
||||
let shown = layout(&d, &mut LayoutCache::new(), &LayoutOptions { show_hidden: true, ..Default::default() });
|
||||
let Placed::Lines { para, .. } = shown.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })).unwrap() else { panic!() };
|
||||
assert!(para.lines[0].left < 1.0 && !shown.pages[0].items.iter().any(|i| matches!(i, Placed::Shape { .. } | Placed::Object { .. })));
|
||||
|
||||
// A group draws its members but a hidden one.
|
||||
let mut d = Document::from_text("");
|
||||
let member = |hidden: bool, x: f32| wordcraft_doc::para::GroupChild {
|
||||
x,
|
||||
y: 0.0,
|
||||
obj: picture(50.0, 50.0, wordcraft_doc::para::Float { hidden, ..Default::default() }),
|
||||
};
|
||||
let group = InlineObject::Group {
|
||||
w: 100.0,
|
||||
h: 50.0,
|
||||
float: Default::default(),
|
||||
ch_w: 100.0,
|
||||
ch_h: 50.0,
|
||||
children: vec![member(true, 0.0), member(false, 50.0)],
|
||||
canvas: None,
|
||||
};
|
||||
d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap();
|
||||
let l = lay(&d);
|
||||
let draws = flat(display::page_display(&d, &l.pages[0], &Default::default()));
|
||||
assert_eq!(draws.iter().filter(|x| matches!(x, display::Draw::Shape { .. })).count(), 1, "{draws:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_float_leaves_no_wrap_area_without_markup() {
|
||||
// A square-wrapped shape anchored at the start of the paragraph, its anchor track-deleted.
|
||||
@@ -2989,6 +3152,24 @@ fn inline_picture_wider_than_the_text_keeps_its_size() {
|
||||
assert!((r.w - 1000.0).abs() < 0.01 && (r.h - 100.0).abs() < 0.01, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_too_wide_after_a_content_control_start_stays_on_the_first_line() {
|
||||
// The picture opening a picture content control (a logo in a text box a little too
|
||||
// narrow for it): the control's start marker before it takes no room, so the picture is
|
||||
// still the line's first item and stays there instead of leaving an empty line above it.
|
||||
let mut d = Document::from_text("");
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.insert_object(0, picture(500.0, 100.0, Default::default()), &Default::default()).unwrap();
|
||||
let start = InlineObject::ControlStart { control: Box::default() };
|
||||
p.insert_object(0, start, &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
assert_eq!(para.lines.len(), 1, "{:?}", para.lines);
|
||||
let line = ¶.lines[0];
|
||||
assert!((line.baseline - line.top - 100.0).abs() < 0.01, "the picture is on the first line: {line:?}");
|
||||
}
|
||||
|
||||
/// The paragraph at `path` on page 0: its first line's x, its y and its line count.
|
||||
fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> {
|
||||
l.pages[0]
|
||||
@@ -4809,3 +4990,132 @@ fn automatic_spacing_drops_between_list_items() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #1116: a document of a top paragraph with `before` points of space before, filler lines, a
|
||||
/// paragraph of several lines (block `t`) with 6 pt after and, when `note`, a footnote on its last line.
|
||||
fn space_after_doc(before: f32, note: bool) -> (Document, usize, Option<u32>) {
|
||||
let text = format!("Top.\n{}{}\nAfter.", "Body text line.\n".repeat(14), "Words in a paragraph that wraps. ".repeat(14));
|
||||
let mut d = Document::from_text(&text);
|
||||
let t = 15;
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.props.space_before = Some(before);
|
||||
}
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(t).map(std::sync::Arc::make_mut) {
|
||||
p.props.space_after = Some(6.0);
|
||||
p.props.widow_control = Some(true);
|
||||
}
|
||||
let len = d.para(StoryRef::Body, &Path(vec![t as u32])).map(|p| p.text.len()).unwrap();
|
||||
let id = note.then(|| footnote(&mut d, &Pos::body(t, len), "The note."));
|
||||
(d, t, id)
|
||||
}
|
||||
|
||||
/// The pieces of body paragraph `t` as (page, l0, l1, bottom), and its line count.
|
||||
fn para_pieces(l: &DocLayout, t: usize) -> (Vec<(usize, usize, usize, f32)>, usize) {
|
||||
let mut v = Vec::new();
|
||||
let mut n = 0;
|
||||
for (pi, p) in l.pages.iter().enumerate() {
|
||||
for it in &p.items {
|
||||
if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = it
|
||||
&& path.0 == [t as u32]
|
||||
{
|
||||
n = para.lines.len();
|
||||
v.push((pi, *l0, *l1, item_bottom(*y, para, *l0, *l1).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
(v, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_stays_clear_of_the_footnote_separator() {
|
||||
// #1116, measured in Word: on a page with footnotes a paragraph ending there keeps its space
|
||||
// after above the separator, or its last lines (two, with widow control) go to the next page
|
||||
// with the note.
|
||||
let mut moved = 0;
|
||||
for before in 0..=300 {
|
||||
let (d, t, id) = space_after_doc(before as f32, true);
|
||||
let l = lay(&d);
|
||||
let (pieces, n) = para_pieces(&l, t);
|
||||
assert!(n >= 4, "a paragraph of several lines: {n}");
|
||||
let &(page, _, l1, bottom) = pieces.last().unwrap();
|
||||
assert_eq!(l1, n);
|
||||
let note = note_pieces(&l, id.unwrap());
|
||||
assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page");
|
||||
// The separator's area (default separator: NOTE_SEP high, its rule half way down) sits
|
||||
// right above the note.
|
||||
let sep_top = note[0].3 - NOTE_SEP;
|
||||
assert!(sep_top - bottom >= 6.0 - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}");
|
||||
if pieces.len() > 1 {
|
||||
assert_eq!(pieces.len(), 2, "{pieces:?}");
|
||||
assert!(pieces[0].2 - pieces[0].1 >= 2 && pieces[1].2 - pieces[1].1 >= 2, "widow/orphan control: {pieces:?}");
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
assert!(moved > 0, "the sweep crosses the page bottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_may_overflow_the_bottom_margin_without_notes() {
|
||||
// Without footnotes nothing changes: the last line only has to fit, its space after may run
|
||||
// past the bottom margin (Word).
|
||||
let mut tight = 0;
|
||||
for before in 0..=300 {
|
||||
let (d, t, _) = space_after_doc(before as f32, false);
|
||||
let l = lay(&d);
|
||||
let bottom_margin = d.last_section.page_h - d.last_section.margin_bottom;
|
||||
let (pieces, _) = para_pieces(&l, t);
|
||||
let &(_, _, _, bottom) = pieces.last().unwrap();
|
||||
assert!(bottom <= bottom_margin + 0.01, "before {before}: {bottom}");
|
||||
if bottom > bottom_margin - 6.0 {
|
||||
tight += 1;
|
||||
}
|
||||
}
|
||||
assert!(tight > 0, "some step leaves less than the space after below the last line");
|
||||
}
|
||||
|
||||
/// #1116: the sweep of `space_after_stays_clear_of_the_footnote_separator` with paragraphs `t`
|
||||
/// and `t + 1` changed by `tweak`: the last line of `t` stays `need` points clear of the separator.
|
||||
fn space_after_clear_with(tweak: impl Fn(&mut Document, usize), need: f32) {
|
||||
for before in 0..=300 {
|
||||
let (mut d, t, id) = space_after_doc(before as f32, true);
|
||||
tweak(&mut d, t);
|
||||
let l = lay(&d);
|
||||
let (pieces, _) = para_pieces(&l, t);
|
||||
let &(page, _, _, bottom) = pieces.last().unwrap();
|
||||
let note = note_pieces(&l, id.unwrap());
|
||||
assert_eq!(note.first().map(|p| p.0), Some(page), "before {before}: the note is on its reference's page");
|
||||
let sep_top = note[0].3 - NOTE_SEP;
|
||||
assert!(sep_top - bottom >= need - 0.01, "before {before}: last line ends at {bottom}, the separator starts at {sep_top}");
|
||||
}
|
||||
}
|
||||
|
||||
fn para_props(d: &mut Document, i: usize) -> &mut ParaProps {
|
||||
match d.body.get_mut(i).map(std::sync::Arc::make_mut) {
|
||||
Some(wordcraft_doc::Block::Para(p)) => &mut p.props,
|
||||
_ => panic!("no paragraph {i}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_stays_clear_when_only_the_next_paragraph_has_contextual_spacing() {
|
||||
// Contextual spacing on the next paragraph alone drops its space before, not this one's space
|
||||
// after (as the spacing between them): the space after still stays clear of the separator.
|
||||
space_after_clear_with(|d, t| para_props(d, t + 1).contextual_spacing = Some(true), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() {
|
||||
// Automatic spacing drops only between two list items with labels: before a paragraph whose
|
||||
// list doesn't exist (no label) a list item's automatic space after stays clear.
|
||||
use wordcraft_doc::props::NumRef;
|
||||
space_after_clear_with(
|
||||
|d, t| {
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let p = para_props(d, t);
|
||||
p.numbering = Some(NumRef { num, level: 0 });
|
||||
p.space_after_auto = Some(true);
|
||||
para_props(d, t + 1).numbering = Some(NumRef { num: 9999, level: 0 });
|
||||
},
|
||||
wordcraft_doc::resolve::AUTO_SPACING,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Conservative Croatian grammar and punctuation checks.
|
||||
//! Handles repeated words, whitespace, capitals and duplicated punctuation;
|
||||
//! intentionally avoids speculative agreement and comma rules.
|
||||
|
||||
use crate::{Issue, IssueKind, ProofLang, words};
|
||||
|
||||
/// Words Croatian repeats on purpose: interjections, onomatopoeia and emphatic doubling.
|
||||
const ALLOWED_REPEATS: &[&str] =
|
||||
&["da", "ne", "no", "ha", "hej", "joj", "aj", "oh", "uh", "hop", "kuc", "tik", "tak", "bum", "malo", "brzo", "polako", "više", "dobro"];
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"npr", "itd", "itp", "tj", "tzv", "odn", "sl", "str", "br", "god", "st", "dr", "mr", "prof", "doc", "ing", "dipl", "mag", "univ", "g", "god",
|
||||
"mj", "min", "sek", "čl", "st", "č", "toč", "tab", "sl", "sv", "ul", "trg", "pr", "izd", "ur", "svez", "vol", "vid", "usp", "prim", "engl",
|
||||
"njem", "hrv", "lat", "tel", "mob", "e", "p", "o", "d", "m", "v", "z", "r", "pon", "uto", "sri", "čet", "pet", "sub", "ned", "sij", "velj",
|
||||
"ožu", "tra", "svi", "lip", "srp", "kol", "ruj", "lis", "stu", "pro",
|
||||
];
|
||||
|
||||
fn issue(start: usize, end: usize, message: String, suggestions: Vec<String>) -> Issue {
|
||||
Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::Hr }
|
||||
}
|
||||
|
||||
fn capitalize(w: &str) -> String {
|
||||
crate::affix::capitalize(w)
|
||||
}
|
||||
|
||||
/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after
|
||||
/// which a lower-case word doesn't start a sentence)?
|
||||
fn abbreviation_or_number(prev: &str) -> bool {
|
||||
prev.chars().count() <= 1
|
||||
|| prev.chars().any(|c| c.is_ascii_digit())
|
||||
|| (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M')))
|
||||
|| ABBREVIATIONS.contains(&prev.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
const CLOSING: [char; 8] = ['"', '”', '’', '\'', ')', '»', ']', '\u{1}'];
|
||||
const OPENING: [char; 8] = ['"', '“', '«', '(', '[', '\'', '‚', '—'];
|
||||
|
||||
/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after
|
||||
/// an abbreviation), then space, with nothing but quotes and brackets around?
|
||||
fn ends_sentence(gap: &str, prev: &str) -> bool {
|
||||
let t = gap.trim_start_matches(CLOSING);
|
||||
let Some(end) = t.chars().next() else { return false };
|
||||
if !matches!(end, '.' | '!' | '?') || t.starts_with("..") {
|
||||
return false;
|
||||
}
|
||||
if end == '.' && abbreviation_or_number(prev) {
|
||||
return false;
|
||||
}
|
||||
let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(CLOSING);
|
||||
rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| OPENING.contains(&c))
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of Croatian text, in order.
|
||||
pub fn check(text: &str) -> Vec<Issue> {
|
||||
let ws: Vec<(usize, usize)> = words(text);
|
||||
let mut v = Vec::new();
|
||||
repeated_words(text, &ws, &mut v);
|
||||
spaces(text, &mut v);
|
||||
sentence_capitals(text, &ws, &mut v);
|
||||
doubled_punctuation(text, &mut v);
|
||||
v.sort_by_key(|i| (i.start, i.end));
|
||||
v
|
||||
}
|
||||
|
||||
fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if !gap.is_empty()
|
||||
&& gap.chars().all(char::is_whitespace)
|
||||
&& w0.chars().all(char::is_alphabetic)
|
||||
&& w0.to_lowercase() == w1.to_lowercase()
|
||||
&& !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str())
|
||||
{
|
||||
v.push(issue(*a0, *b1, format!("Ponovljena riječ: \"{w1}\""), vec![w0.to_string()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spaces(text: &str, v: &mut Vec<Issue>) {
|
||||
for (i, c) in text.char_indices() {
|
||||
if c != ' ' {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(i + 1..).unwrap_or("");
|
||||
let mut it = rest.chars();
|
||||
let Some(p) = it.next() else { continue };
|
||||
let after = it.next();
|
||||
let flag = match p {
|
||||
',' => true,
|
||||
// `;)` and `;-)` are emoticons.
|
||||
';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')),
|
||||
// ` ...` is an ellipsis, ` .5` a number.
|
||||
'.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()),
|
||||
// ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space.
|
||||
':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '!' | '?')),
|
||||
_ => false,
|
||||
};
|
||||
if flag {
|
||||
v.push(issue(i, i + 1 + p.len_utf8(), "Razmak ispred interpunkcijskog znaka".into(), vec![p.to_string()]));
|
||||
}
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(i, i + 2, "Dvostruki razmak".into(), vec![" ".into()]));
|
||||
}
|
||||
}
|
||||
|
||||
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
// List items (`– maçãs,`) may start in lower case.
|
||||
let list_item = text.trim_end().ends_with([',', ';']);
|
||||
for (k, &(a, b)) in ws.iter().enumerate() {
|
||||
let Some(w) = text.get(a..b) else { continue };
|
||||
let starts = if k == 0 {
|
||||
let before = text.get(..a).unwrap_or("");
|
||||
!list_item && before.chars().all(|c| c.is_whitespace() || OPENING.contains(&c))
|
||||
} else {
|
||||
let Some(&(pa, pb)) = ws.get(k - 1) else { continue };
|
||||
ends_sentence(text.get(pb..a).unwrap_or(""), text.get(pa..pb).unwrap_or(""))
|
||||
};
|
||||
if starts && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||
v.push(issue(a, b, "Rečenica treba započeti velikim slovom".into(), vec![capitalize(w)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn doubled_punctuation(text: &str, v: &mut Vec<Issue>) {
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if !matches!(b, b',' | b'.' | b';') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let run = bytes.get(i..).map_or(0, |r| r.iter().take_while(|&&x| x == b).count());
|
||||
let flag = match b {
|
||||
b'.' => run == 2,
|
||||
_ => run >= 2,
|
||||
};
|
||||
if flag {
|
||||
let end = i + run;
|
||||
let suggestions: Vec<String> = match b {
|
||||
b'.' => vec![".".into(), "…".into()],
|
||||
_ => vec![char::from(b).to_string()],
|
||||
};
|
||||
v.push(issue(i, end, "Ponovljen interpunkcijski znak".into(), suggestions));
|
||||
}
|
||||
i += run.max(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn checks_croatian_punctuation() {
|
||||
let t = "Ovo ovo je primjer , s dva razmaka..";
|
||||
let issues = check(t);
|
||||
assert!(issues.iter().any(|i| i.message.starts_with("Ponovljena riječ")));
|
||||
assert!(issues.iter().any(|i| i.message == "Dvostruki razmak"));
|
||||
assert!(issues.iter().all(|i| i.lang == ProofLang::Hr));
|
||||
}
|
||||
#[test]
|
||||
fn abbreviations_do_not_trigger_capitals() {
|
||||
let issues = check("To je npr. sasvim dobar primjer.");
|
||||
assert!(!issues.iter().any(|i| i.message == "Rečenica treba započeti velikim slovom"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Croatian spelling and conservative grammar support.
|
||||
//! Uses an installed hr_HR Hunspell .aff/.dic pair (not bundled).
|
||||
//! The engine decodes ISO-8859-2 dictionaries at load time.
|
||||
//! Suggestions prioritize č/ć, đ, š, ž and common one-character mistakes.
|
||||
//! Hyphenation remains the host engine's fallback until Croatian patterns are supplied.
|
||||
|
||||
pub mod grammar;
|
||||
mod suggest;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_dict;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::affix::AffixDict;
|
||||
|
||||
pub use suggest::suggest;
|
||||
|
||||
/// The Croatian dictionary: `None` until first use, then the installed one (or an empty one).
|
||||
static DICT: RwLock<Option<Arc<AffixDict>>> = RwLock::new(None);
|
||||
|
||||
/// File names of a Croatian Hunspell dictionary.
|
||||
const NAMES: &[&str] = &["hr_HR", "hr-HR", "hr"];
|
||||
|
||||
/// The Croatian dictionary: the installed Hunspell `hr_HR` (read on first use), one given with
|
||||
/// [`install_dictionary`], or an empty one when none is available (Croatian spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
|
||||
fn load_installed() -> AffixDict {
|
||||
let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
|
||||
log::info!("Croatian spelling: no Hunspell hr_HR dictionary installed");
|
||||
return AffixDict::default();
|
||||
};
|
||||
|
||||
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| {
|
||||
log::warn!("Croatian spelling: {e}");
|
||||
AffixDict::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Use this Croatian dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Is a Croatian dictionary available (spelling is checked)?
|
||||
pub fn has_dictionary() -> bool {
|
||||
!dictionary().is_empty()
|
||||
}
|
||||
|
||||
/// Is `c` a letter of the Latin script (the only one the Croatian dictionary covers)?
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c))
|
||||
}
|
||||
|
||||
/// Is `word` spelled correctly in Croatian? Numbers, single letters, short words in capitals
|
||||
/// (acronyms: `HRT`, `RH`), words with digits and words in other scripts are accepted, and so
|
||||
/// is every word when no Croatian dictionary is installed.
|
||||
pub fn is_correct(word: &str) -> bool {
|
||||
is_correct_with(&dictionary(), word)
|
||||
}
|
||||
|
||||
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
|
||||
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
|
||||
if d.is_empty() {
|
||||
return true; // no Croatian dictionary installed: no spelling marks
|
||||
}
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
|
||||
let w = w.trim_matches(['\'', '’']);
|
||||
let n = w.chars().count();
|
||||
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
|
||||
return true;
|
||||
}
|
||||
if !w.chars().any(is_latin_letter) {
|
||||
return true; // other scripts: no dictionary
|
||||
}
|
||||
if w.contains('-') {
|
||||
return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p));
|
||||
}
|
||||
known(d, w)
|
||||
}
|
||||
|
||||
/// Is one word (no hyphens) in the dictionary or the user dictionary?
|
||||
fn known(d: &AffixDict, w: &str) -> bool {
|
||||
d.check(w) || crate::in_user_dictionary(&w.to_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dictionary_loads() {
|
||||
test_dict::install();
|
||||
let d = dictionary();
|
||||
assert!(has_dictionary());
|
||||
assert!(d.len() > 15);
|
||||
assert!(d.rule_count() > 0);
|
||||
assert!(!d.map().is_empty() && !d.rep().is_empty() && !d.try_chars().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_words_and_affixes() {
|
||||
test_dict::install();
|
||||
for w in ["kuća", "kuće", "računalo", "čovjek", "riječ", "dijete", "Hrvatska", "čokolada", "škola", "škole", "HRT", "2026"] {
|
||||
assert!(is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspellings_are_flagged() {
|
||||
test_dict::install();
|
||||
for w in ["kuca", "racunalo", "covjek", "rijec", "dijeteh", "skolla"] {
|
||||
assert!(!is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dictionary_is_silent() {
|
||||
assert!(is_correct_with(&AffixDict::default(), "nepostojećariječ"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Croatian spelling suggestions using dictionary MAP/REP rules, edit distance,
|
||||
//! missing spaces and a bounded search. Proper Croatian corrections depend on
|
||||
//! the actual installed dictionary entries.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
/// Short words often run into the next one (`não é`, `da água`, `para isso`).
|
||||
const LEADING_WORDS: &[&str] = &[
|
||||
"a", "ali", "i", "ili", "da", "do", "od", "na", "ne", "ni", "o", "po", "pod", "pri", "sa", "s", "se", "su", "u", "uz", "za", "iz", "je", "će",
|
||||
"bi", "bih", "što", "koji", "koja", "koje", "kao", "kad", "dok", "bez", "pred", "nad",
|
||||
];
|
||||
const TRAILING_WORDS: &[&str] =
|
||||
&["je", "su", "se", "ga", "mi", "ti", "mu", "joj", "ih", "im", "li", "će", "ću", "bi", "bih", "i", "a", "u", "na", "za", "od", "do"];
|
||||
|
||||
const MAX_LETTERS: usize = 40;
|
||||
const MAX_MAP_CHANGES: usize = 4;
|
||||
const MAX_MAP_VARIANTS: usize = 4096;
|
||||
/// Stems the scan of round 6 expands.
|
||||
const SCAN_STEMS: usize = 48;
|
||||
|
||||
const COST_CASE: u32 = 0;
|
||||
const COST_ADD_DIACRITIC: u32 = 4;
|
||||
const COST_OTHER_MAP: u32 = 7;
|
||||
const COST_DROP_DIACRITIC: u32 = 11;
|
||||
const COST_REP: u32 = 10;
|
||||
const COST_SPLIT: u32 = 12;
|
||||
const COST_SPLIT_RARE: u32 = 18;
|
||||
const COST_SWAP: u32 = 14;
|
||||
const COST_DROP_OR_ADD: u32 = 15;
|
||||
const COST_MISTYPE: u32 = 16;
|
||||
const COST_SCAN: u32 = 20;
|
||||
|
||||
struct Collector<'a> {
|
||||
d: &'a AffixDict,
|
||||
/// The word, lower case.
|
||||
input: String,
|
||||
tested: HashSet<String>,
|
||||
/// Suggestion → (cost, order found).
|
||||
found: HashMap<String, (u32, usize)>,
|
||||
seq: usize,
|
||||
}
|
||||
|
||||
impl Collector<'_> {
|
||||
/// Check a lower-case candidate (a word, or two separated by a space) and keep it if every
|
||||
/// word is in the dictionary, as written or capitalized (names).
|
||||
fn offer(&mut self, cand: &str, cost: u32) {
|
||||
if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) {
|
||||
return;
|
||||
}
|
||||
let mut words = Vec::new();
|
||||
let mut cost = cost;
|
||||
for part in cand.split(' ') {
|
||||
if self.d.contains(part) {
|
||||
words.push(part.to_string());
|
||||
} else {
|
||||
let c = capitalize(part);
|
||||
if !self.d.contains(&c) {
|
||||
return;
|
||||
}
|
||||
words.push(c);
|
||||
// A name ranks after a common word at the same distance (`moça`, then `Moça`).
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
self.keep(words.join(" "), cost);
|
||||
}
|
||||
|
||||
/// Keep a suggestion known to be correct.
|
||||
fn keep(&mut self, text: String, cost: u32) {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
let e = self.found.entry(text).or_insert((cost, seq));
|
||||
if cost < e.0 {
|
||||
*e = (cost, seq);
|
||||
}
|
||||
}
|
||||
|
||||
fn count_within(&self, cost: u32) -> usize {
|
||||
self.found.values().filter(|(c, _)| *c <= cost).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// The MAP group of each letter (its other members are its likely confusions).
|
||||
fn alternatives(c: char, map: &[Vec<char>]) -> Vec<char> {
|
||||
map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect()
|
||||
}
|
||||
|
||||
/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing accent is the
|
||||
/// commonest slip (Croatian typed without accents: `coracao`); an accent the writer did type
|
||||
/// is likelier right, so taking one away costs more than a known confusion from the REP table
|
||||
/// (`seje` → `seja` before `sejé`).
|
||||
fn map_cost(from: char, to: char) -> u32 {
|
||||
match (from.is_ascii(), to.is_ascii()) {
|
||||
(true, false) => COST_ADD_DIACRITIC,
|
||||
(false, true) => COST_DROP_DIACRITIC,
|
||||
_ => COST_OTHER_MAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost.
|
||||
fn map_variants(chars: &[char], alts: &[Vec<char>], i: usize, changes: usize, cost: u32, buf: &mut Vec<char>, out: &mut Vec<(String, u32)>) {
|
||||
if out.len() >= MAX_MAP_VARIANTS {
|
||||
return;
|
||||
}
|
||||
let Some(&c) = chars.get(i) else {
|
||||
if changes > 0 {
|
||||
out.push((buf.iter().collect(), cost));
|
||||
}
|
||||
return;
|
||||
};
|
||||
buf.push(c);
|
||||
map_variants(chars, alts, i + 1, changes, cost, buf, out);
|
||||
buf.pop();
|
||||
if changes < MAX_MAP_CHANGES {
|
||||
for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() {
|
||||
buf.push(a);
|
||||
map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out);
|
||||
buf.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter's MAP representative (the first of its group), lower case: `ã` → `a`, `ç` → `c`.
|
||||
fn fold(c: char, map: &[Vec<char>]) -> char {
|
||||
let l = c.to_lowercase().next().unwrap_or(c);
|
||||
map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l)
|
||||
}
|
||||
|
||||
/// Optimal string alignment distance where a MAP slip (`a`/`á`, `c`/`ç`) costs 1 and any other
|
||||
/// edit 2; `limit + 1` once it is certainly above `limit`.
|
||||
fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 {
|
||||
let m = b.len();
|
||||
let over = limit + 1;
|
||||
let mut prev2 = vec![0u32; m + 1];
|
||||
let mut prev: Vec<u32> = (0..=m as u32).map(|j| j * 2).collect();
|
||||
let mut cur = vec![0u32; m + 1];
|
||||
for (i, &ai) in a.iter().enumerate() {
|
||||
if let Some(c) = cur.get_mut(0) {
|
||||
*c = (i as u32 + 1) * 2;
|
||||
}
|
||||
let mut row_min = u32::MAX;
|
||||
for (j, &bj) in b.iter().enumerate() {
|
||||
let sub = if ai == bj {
|
||||
0
|
||||
} else if related(ai, bj) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4);
|
||||
let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub);
|
||||
if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) {
|
||||
v = v.min(at(&prev2, j - 1) + 2);
|
||||
}
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = v;
|
||||
}
|
||||
row_min = row_min.min(v);
|
||||
}
|
||||
if m > 0 && row_min > limit {
|
||||
return over;
|
||||
}
|
||||
std::mem::swap(&mut prev2, &mut prev);
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev.get(m).copied().unwrap_or(over).min(over)
|
||||
}
|
||||
|
||||
/// Spelling suggestions for a Croatian word, best first, at most `max`, in the word's letter case.
|
||||
pub fn suggest(word: &str, max: usize) -> Vec<String> {
|
||||
suggest_in(&super::dictionary(), word, max)
|
||||
}
|
||||
|
||||
/// [`suggest`] against a given dictionary.
|
||||
pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec<String> {
|
||||
let word = word.trim();
|
||||
let n = word.chars().count();
|
||||
if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) {
|
||||
return Vec::new();
|
||||
}
|
||||
let case = casing(word);
|
||||
let input = word.to_lowercase();
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 };
|
||||
// 1. Letter case.
|
||||
if d.contains(&input) && word != input {
|
||||
col.keep(input.clone(), COST_CASE);
|
||||
} else if d.contains(&capitalize(&input)) && word != capitalize(&input) {
|
||||
col.keep(capitalize(&input), COST_CASE);
|
||||
}
|
||||
// 2. Accents and other MAP slips.
|
||||
let fallback_map = vec![vec!['c', 'č', 'ć'], vec!['d', 'đ'], vec!['s', 'š'], vec!['z', 'ž']];
|
||||
let map = if d.map().is_empty() { &fallback_map } else { d.map() };
|
||||
let alts: Vec<Vec<char>> = chars.iter().map(|&c| alternatives(c, map)).collect();
|
||||
let mut variants = Vec::new();
|
||||
map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants);
|
||||
variants.sort_by_key(|v| v.1);
|
||||
for (v, cost) in &variants {
|
||||
col.offer(v, *cost);
|
||||
}
|
||||
// 3. Common confusions.
|
||||
for (from, to) in d.rep() {
|
||||
if from.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, _) in input.match_indices(from.as_str()) {
|
||||
if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) {
|
||||
col.offer(&format!("{a}{to}{b}"), COST_REP);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. A missing space: cheap after a short leading word or before a short trailing one,
|
||||
// dearer between two longer words, never leaving a stray short half.
|
||||
for (i, _) in input.char_indices().skip(1) {
|
||||
let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue };
|
||||
let long = |p: &str| p.chars().count() >= 3;
|
||||
let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) {
|
||||
COST_SPLIT
|
||||
} else if long(a) && long(b) {
|
||||
COST_SPLIT_RARE
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
col.offer(&format!("{a} {b}"), cost);
|
||||
}
|
||||
// 5. One typo.
|
||||
let mut letters: Vec<char> = d.try_chars().chars().filter(|c| c.is_lowercase()).collect();
|
||||
if letters.is_empty() {
|
||||
letters = "aeiourntsjkvlmdpbgczčćšžđfh".chars().collect();
|
||||
}
|
||||
let s = |v: &[char]| v.iter().collect::<String>();
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if chars.get(i) != chars.get(i + 1) {
|
||||
let mut v = chars.clone();
|
||||
v.swap(i, i + 1);
|
||||
col.offer(&s(&v), COST_SWAP);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut v = chars.clone();
|
||||
v.remove(i);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
for i in 0..=n {
|
||||
for &c in &letters {
|
||||
let mut v = chars.clone();
|
||||
v.insert(i, c);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
for &c in &letters {
|
||||
if chars.get(i) != Some(&c) {
|
||||
let mut v = chars.clone();
|
||||
if let Some(x) = v.get_mut(i) {
|
||||
*x = c;
|
||||
}
|
||||
col.offer(&s(&v), COST_MISTYPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 6. Further off: scan the stems that start like the word.
|
||||
if col.count_within(COST_MISTYPE) < max {
|
||||
scan(&mut col, &chars);
|
||||
}
|
||||
let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect();
|
||||
// Among equal costs, the change nearest the word's start is the likelier intent
|
||||
// (`saúde` before `saudê`: both add an accent, but at the first difference).
|
||||
let diff_at = |text: &str| chars.iter().zip(text.chars()).position(|(a, b)| *a != b).unwrap_or(n);
|
||||
found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq));
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (text, _) in found {
|
||||
let t = match case {
|
||||
Casing::Initial => capitalize(&text),
|
||||
Casing::Upper if n > 1 => text.to_uppercase(),
|
||||
_ => text,
|
||||
};
|
||||
if t != word && !out.contains(&t) {
|
||||
out.push(t);
|
||||
}
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either
|
||||
/// case) by shared letter pairs, then keep the forms of the best within two typos.
|
||||
fn scan(col: &mut Collector, chars: &[char]) {
|
||||
let d = col.d;
|
||||
let map = d.map();
|
||||
let Some(&first) = chars.first() else { return };
|
||||
let folded: Vec<char> = chars.iter().map(|&c| fold(c, map)).collect();
|
||||
let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect();
|
||||
let mut firsts: Vec<char> = std::iter::once(first).chain(alternatives(first, map)).collect();
|
||||
let upper: Vec<char> = firsts.iter().flat_map(|c| c.to_uppercase()).collect();
|
||||
firsts.extend(upper);
|
||||
firsts.sort_unstable();
|
||||
firsts.dedup();
|
||||
let n = folded.len();
|
||||
let mut best: Vec<(u32, &str)> = Vec::new();
|
||||
for f in firsts {
|
||||
let prefix = f.to_string();
|
||||
for stem in d.stems_with_prefix(&prefix) {
|
||||
let sf: Vec<char> = stem.chars().map(|c| fold(c, map)).collect();
|
||||
let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count();
|
||||
if shared < 2 {
|
||||
continue;
|
||||
}
|
||||
// Shared pairs, less the stem's letters the word doesn't explain, less a length gap
|
||||
// beyond what an ending explains.
|
||||
let extra = sf.len().saturating_sub(shared);
|
||||
let gap = sf.len().abs_diff(n).saturating_sub(4);
|
||||
let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32;
|
||||
if score == 0 {
|
||||
continue;
|
||||
}
|
||||
if best.len() < SCAN_STEMS {
|
||||
best.push((score, stem));
|
||||
} else if let Some(min) = best.iter_mut().min_by_key(|b| b.0)
|
||||
&& score > min.0
|
||||
{
|
||||
*min = (score, stem);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
|
||||
let related = |a: char, b: char| fold(a, map) == fold(b, map);
|
||||
let limit = 4;
|
||||
for (_, stem) in best {
|
||||
for form in d.forms(stem) {
|
||||
let lf: Vec<char> = form.to_lowercase().chars().collect();
|
||||
if lf.len().abs_diff(chars.len()) > 2 {
|
||||
continue;
|
||||
}
|
||||
let dist = weighted_distance(&lf, chars, &related, limit);
|
||||
if dist <= limit && lf.as_slice() != chars {
|
||||
col.keep(form, COST_SCAN + dist * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn croatian_suggestions() {
|
||||
crate::hr::test_dict::install();
|
||||
for (wrong, right) in [("kuca", "kuća"), ("racunalo", "računalo"), ("covjek", "čovjek"), ("rijec", "riječ"), ("skolla", "škola")] {
|
||||
let result = suggest(wrong, 10);
|
||||
assert!(result.iter().any(|s| s == right), "{wrong} -> {result:?}, expected {right}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bounded() {
|
||||
crate::hr::test_dict::install();
|
||||
assert!(suggest("", 5).is_empty());
|
||||
assert!(suggest("kuca", 0).is_empty());
|
||||
assert!(suggest(&"a".repeat(41), 5).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Small synthetic UTF-8 Croatian Hunspell dictionary for deterministic tests.
|
||||
pub const AFF: &str = "SET UTF-8
|
||||
TRY abcčćdđefghijklmnoprsštuvzž
|
||||
MAP 5
|
||||
MAP cčć
|
||||
MAP dđ
|
||||
MAP sš
|
||||
MAP zž
|
||||
MAP iije
|
||||
REP 3
|
||||
REP c č
|
||||
REP dj đ
|
||||
REP je ije
|
||||
SFX A Y 2
|
||||
SFX A a e a
|
||||
SFX A a i a
|
||||
";
|
||||
pub const DIC: &str = "18
|
||||
kuća/A
|
||||
računalo
|
||||
čovjek
|
||||
riječ
|
||||
dijete
|
||||
Hrvatska
|
||||
čokolada
|
||||
škola/A
|
||||
more
|
||||
sunce
|
||||
dobar
|
||||
hrvatski
|
||||
jezik
|
||||
tekst
|
||||
pogreška
|
||||
pravopis
|
||||
provjera
|
||||
računala
|
||||
";
|
||||
pub fn install() {
|
||||
if let Err(e) = super::install_dictionary(AFF, DIC) {
|
||||
panic!("test dictionary: {e}");
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat");
|
||||
static PL_PAT: &str = include_str!("../../../assets/hyphenation/pl.pat");
|
||||
/// hyph-utf8's `hyph-pt.tex`, unmodified (its BSD 3-clause header is in the file).
|
||||
static PT_PAT: &str = include_str!("../../../assets/hyphenation/pt.pat");
|
||||
/// hyph-utf8's `hyph-it.tex`, unmodified (offered under MIT or LPPL; WordCraft uses MIT, whose
|
||||
/// notice is in the file's header).
|
||||
static IT_PAT: &str = include_str!("../../../assets/hyphenation/it.pat");
|
||||
|
||||
impl Dictionary {
|
||||
/// The bundled US English dictionary (decoded on first use).
|
||||
@@ -68,6 +71,12 @@ impl Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(PT_PAT))
|
||||
}
|
||||
|
||||
/// The bundled Italian patterns (parsed on first use; the file has no exception words).
|
||||
pub fn it() -> &'static Patterns {
|
||||
static P: OnceLock<Patterns> = OnceLock::new();
|
||||
P.get_or_init(|| Patterns::parse(IT_PAT))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyphenation limits from the paragraph's Hyphenation settings.
|
||||
@@ -121,6 +130,9 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec<usize>, Source) {
|
||||
if lang == ProofLang::Pt {
|
||||
return core_breaks_pt(core);
|
||||
}
|
||||
if lang == ProofLang::It {
|
||||
return core_breaks_it(core);
|
||||
}
|
||||
let dict = Dictionary::en_us();
|
||||
let s: String = core.iter().collect();
|
||||
let from_mask = |m: u64, len: usize| (1..len.min(64)).filter(|&i| m & (1 << i) != 0).collect::<Vec<_>>();
|
||||
@@ -175,6 +187,22 @@ fn core_breaks_pt(core: &[char]) -> (Vec<usize>, Source) {
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// [`core_breaks_pt`] for Italian: the bundled patterns (the file has no exception words),
|
||||
/// then the generic heuristic.
|
||||
fn core_breaks_it(core: &[char]) -> (Vec<usize>, Source) {
|
||||
let pats = Patterns::it();
|
||||
let s: String = core.iter().collect();
|
||||
if let Some(p) = pats.exception(&s) {
|
||||
return (p.to_vec(), Source::Dictionary);
|
||||
}
|
||||
if !pats.is_empty()
|
||||
&& let Some(p) = pats.points(core)
|
||||
{
|
||||
return (p, Source::Patterns);
|
||||
}
|
||||
(heuristic_points(core), Source::Heuristic)
|
||||
}
|
||||
|
||||
/// Unrestricted break points of an English `word` ([`word_breaks_in`] with [`ProofLang::En`]).
|
||||
pub fn word_breaks(word: &str) -> (Vec<usize>, Source) {
|
||||
word_breaks_in(word, ProofLang::En)
|
||||
@@ -706,6 +734,44 @@ mod tests {
|
||||
hyphenate_word_in(w, &Limits::default(), ProofLang::Pt)
|
||||
}
|
||||
|
||||
fn it(w: &str) -> String {
|
||||
hyphenate_word_in(w, &Limits::default(), ProofLang::It)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_patterns_load() {
|
||||
let p = Patterns::it();
|
||||
assert!(p.len() > 300, "{}", p.len());
|
||||
// The file has no exception words.
|
||||
assert_eq!(p.exception_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_words_hyphenate_by_italian_rules() {
|
||||
for (w, want) in [
|
||||
("famiglia", "fa-mi-glia"),
|
||||
("finestra", "fi-ne-stra"),
|
||||
("bicicletta", "bi-ci-clet-ta"),
|
||||
("passeggiata", "pas-seg-gia-ta"),
|
||||
("caffè", "caf-fè"),
|
||||
("tavolo", "ta-vo-lo"),
|
||||
("bambino", "bam-bi-no"),
|
||||
("scuola", "scuo-la"),
|
||||
("notte", "not-te"),
|
||||
("giorno", "gior-no"),
|
||||
("acqua", "ac-qua"),
|
||||
// Under five letters (`Limits::min_word`): no break.
|
||||
("casa", "casa"),
|
||||
("pane", "pane"),
|
||||
("libro", "li-bro"),
|
||||
("re", "re"),
|
||||
("più", "più"),
|
||||
] {
|
||||
assert_eq!(it(w), want, "{w}");
|
||||
}
|
||||
assert_eq!(word_breaks_in("famiglia", ProofLang::It).1, Source::Patterns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portuguese_patterns_load() {
|
||||
let p = Patterns::pt();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Italian grammar and punctuation checks: cheap, rule-based and tuned to stay quiet when
|
||||
//! unsure (a missed mistake costs less than a wrong mark on correct text). Messages are in
|
||||
//! Italian.
|
||||
//!
|
||||
//! - **Repeated word** (`la la`), except words Italian doubles on purpose (`piano piano`,
|
||||
//! `sì sì`, interjections).
|
||||
//! - **Space before punctuation** (`bene ,`) and **double spaces**; emoticons (` :)`) and
|
||||
//! ellipses are left alone.
|
||||
//! - **Capital letter at the start of a sentence**, knowing Italian abbreviations (`ecc.`,
|
||||
//! `dott.`, `prof.`, `sig.`), initials, ordinal numbers (`1. posto`) and ellipses.
|
||||
//! - **Doubled punctuation** (`,,`, `..`, `;;`).
|
||||
//!
|
||||
//! No comma rule and no English rule (such as `a`/`an`) applies to Italian text.
|
||||
|
||||
use crate::{Issue, IssueKind, ProofLang, words};
|
||||
|
||||
/// Words Italian repeats on purpose: emphatic doubling, interjections, onomatopoeia.
|
||||
const ALLOWED_REPEATS: &[&str] = &[
|
||||
"sì", "no", "piano", "bene", "male", "ecco", "oh", "ah", "eh", "mh", "bah", "ehi", "urrà", "evviva", "ciao", "addio", "adagio", "forte", "lento",
|
||||
"chiaro",
|
||||
];
|
||||
|
||||
/// Abbreviations written with a final period after which a sentence goes on in lower case
|
||||
/// (lower case, without the period).
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"ecc",
|
||||
"es",
|
||||
"dott",
|
||||
"prof",
|
||||
"profssa",
|
||||
"ing",
|
||||
"avv",
|
||||
"sig",
|
||||
"sigg",
|
||||
"sigra",
|
||||
"sigr",
|
||||
"geom",
|
||||
"rag",
|
||||
"dottssa",
|
||||
"avvoc",
|
||||
"pag",
|
||||
"pagg",
|
||||
"tel",
|
||||
"art",
|
||||
"artt",
|
||||
"fig",
|
||||
"figg",
|
||||
"vol",
|
||||
"voll",
|
||||
"ed",
|
||||
"sez",
|
||||
"cap",
|
||||
"capp",
|
||||
"par",
|
||||
"n",
|
||||
"num",
|
||||
"vs",
|
||||
"cfr",
|
||||
"cfrt",
|
||||
"op",
|
||||
"cit",
|
||||
"ibid",
|
||||
"sez",
|
||||
"all",
|
||||
"tav",
|
||||
"pag",
|
||||
"ditta",
|
||||
"soc",
|
||||
"spa",
|
||||
"srl",
|
||||
"on",
|
||||
"sen",
|
||||
"spett",
|
||||
"eg",
|
||||
"ca",
|
||||
"v",
|
||||
"vv",
|
||||
"sec",
|
||||
"min",
|
||||
"max",
|
||||
"lun",
|
||||
"mar",
|
||||
"mer",
|
||||
"gio",
|
||||
"ven",
|
||||
"sab",
|
||||
"dom",
|
||||
"gen",
|
||||
"feb",
|
||||
"mar",
|
||||
"apr",
|
||||
"mag",
|
||||
"giu",
|
||||
"lug",
|
||||
"ago",
|
||||
"set",
|
||||
"ott",
|
||||
"nov",
|
||||
"dic",
|
||||
"genn",
|
||||
"febb",
|
||||
"sett",
|
||||
"dottor",
|
||||
"professor",
|
||||
"cav",
|
||||
"commend",
|
||||
"mons",
|
||||
"fr",
|
||||
"suor",
|
||||
"s",
|
||||
"ss",
|
||||
"b",
|
||||
"beato",
|
||||
];
|
||||
|
||||
fn issue(start: usize, end: usize, message: String, suggestions: Vec<String>) -> Issue {
|
||||
Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang: ProofLang::It }
|
||||
}
|
||||
|
||||
/// Is `prev`, before a period, an abbreviation, an initial, a number or a Roman numeral (after
|
||||
/// which a lower-case word doesn't start a sentence)?
|
||||
fn abbreviation_or_number(prev: &str) -> bool {
|
||||
prev.chars().count() <= 1
|
||||
|| prev.chars().any(|c| c.is_ascii_digit())
|
||||
|| (prev.chars().all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M')))
|
||||
|| ABBREVIATIONS.contains(&prev.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
/// Does the gap between two words end a sentence: a `.`, `!` or `?` (not an ellipsis, not after
|
||||
/// an abbreviation), then space, with nothing but quotes and brackets around?
|
||||
fn ends_sentence(gap: &str, prev: &str) -> bool {
|
||||
let t = gap.trim_start_matches(['"', '”', '’', '\'', ')', '»', ']', '\u{1}']);
|
||||
let Some(end) = t.chars().next() else { return false };
|
||||
if !matches!(end, '.' | '!' | '?') || t.starts_with("..") {
|
||||
return false;
|
||||
}
|
||||
if end == '.' && abbreviation_or_number(prev) {
|
||||
return false;
|
||||
}
|
||||
let rest = t.trim_start_matches(['.', '!', '?']).trim_start_matches(['"', '”', '’', '\'', ')', '»', ']']);
|
||||
rest.starts_with(char::is_whitespace) && rest.trim_start().chars().all(|c| matches!(c, '"' | '“' | '«' | '(' | '[' | '\'' | '—'))
|
||||
}
|
||||
|
||||
fn repeated_words(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
for pair in ws.windows(2) {
|
||||
let [(a0, b0), (a1, b1)] = pair else { continue };
|
||||
let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue };
|
||||
if !gap.is_empty()
|
||||
&& gap.chars().all(char::is_whitespace)
|
||||
&& w0.chars().all(char::is_alphabetic)
|
||||
&& w0.to_lowercase() == w1.to_lowercase()
|
||||
&& !ALLOWED_REPEATS.contains(&w0.to_lowercase().as_str())
|
||||
{
|
||||
v.push(issue(*a0, *b1, format!("Parola ripetuta: \"{w1}\""), vec![w0.to_string()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spaces(text: &str, v: &mut Vec<Issue>) {
|
||||
for (i, c) in text.char_indices() {
|
||||
if c != ' ' {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(i + 1..).unwrap_or("");
|
||||
let mut it = rest.chars();
|
||||
let Some(p) = it.next() else { continue };
|
||||
let after = it.next();
|
||||
let flag = match p {
|
||||
',' => true,
|
||||
// `;)` and `;-)` are emoticons.
|
||||
';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')),
|
||||
// ` ...` is an ellipsis, ` .5` a number.
|
||||
'.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()),
|
||||
// ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space.
|
||||
':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '!' | '?')),
|
||||
_ => false,
|
||||
};
|
||||
if flag {
|
||||
v.push(issue(i, i + 1 + p.len_utf8(), "Spazio prima della punteggiatura".into(), vec![p.to_string()]));
|
||||
}
|
||||
}
|
||||
for (i, _) in text.match_indices(" ") {
|
||||
if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) {
|
||||
continue;
|
||||
}
|
||||
v.push(issue(i, i + 2, "Spazio doppio".into(), vec![" ".into()]));
|
||||
}
|
||||
}
|
||||
|
||||
fn sentence_capitals(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
|
||||
let mut after_end = true;
|
||||
for (a, b) in ws {
|
||||
let Some(w) = text.get(*a..*b) else { continue };
|
||||
if after_end && w.chars().next().is_some_and(char::is_lowercase) && w.chars().all(char::is_alphabetic) {
|
||||
let fixed: String = w.chars().next().map(|f| f.to_uppercase().chain(w.chars().skip(1)).collect()).unwrap_or_default();
|
||||
v.push(issue(*a, *b, "Maiuscola a inizio frase".into(), vec![fixed]));
|
||||
}
|
||||
after_end = ends_sentence(text.get(*b..).unwrap_or(""), w);
|
||||
}
|
||||
}
|
||||
|
||||
fn doubled_punctuation(text: &str, v: &mut Vec<Issue>) {
|
||||
for pat in [",,", "..", ";;", "::", "??", "!!"] {
|
||||
for (i, _) in text.match_indices(pat) {
|
||||
if pat == ".." && text.get(i + 2..).is_some_and(|r| r.starts_with('.')) {
|
||||
continue; // ellipsis
|
||||
}
|
||||
v.push(issue(i, i + pat.len(), "Punteggiatura doppia".into(), vec![pat[..1].into()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of Italian text, in order.
|
||||
pub fn check(text: &str) -> Vec<Issue> {
|
||||
let ws: Vec<(usize, usize)> = words(text);
|
||||
let mut v = Vec::new();
|
||||
repeated_words(text, &ws, &mut v);
|
||||
spaces(text, &mut v);
|
||||
sentence_capitals(text, &ws, &mut v);
|
||||
doubled_punctuation(text, &mut v);
|
||||
v.sort_by_key(|i| (i.start, i.end));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn italian_grammar_flags() {
|
||||
test_dict_install();
|
||||
let t = "questo questo è un test , fatto male.. Vedi dott. rossi";
|
||||
let v = check(t);
|
||||
let msgs: Vec<&str> = v.iter().map(|i| i.message.as_str()).collect();
|
||||
assert!(msgs.iter().any(|m| m.contains("ripetuta")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("prima della punteggiatura")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("doppio")), "{msgs:?}");
|
||||
assert!(msgs.iter().any(|m| m.contains("doppia")), "{msgs:?}");
|
||||
assert!(v.iter().all(|i| i.lang == ProofLang::It), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_grammar_stays_quiet() {
|
||||
test_dict_install();
|
||||
assert!(
|
||||
check("Vado a Roma. Il dott. Rossi è già lì... piano piano!").is_empty(),
|
||||
"{:?}",
|
||||
check("Vado a Roma. Il dott. Rossi è già lì... piano piano!")
|
||||
);
|
||||
assert!(check("Sì sì, bene bene :)").is_empty());
|
||||
}
|
||||
|
||||
fn test_dict_install() {
|
||||
crate::it::test_dict::install();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Italian proofing.
|
||||
//!
|
||||
//! - **Spelling** against the Hunspell `it_IT` dictionary installed on the system (found by
|
||||
//! [`crate::hunspell`]: `it_IT.aff`/`it_IT.dic` from the OS, a package manager or
|
||||
//! LibreOffice; it isn't bundled — the upstream file is GPL-licensed). Without it Italian
|
||||
//! text gets no spelling marks. Elided forms check the word after the apostrophe
|
||||
//! (`l'amico`, `un'idea`, `c'è`); hyphenated compounds are checked part by part; the user
|
||||
//! dictionary applies as in English.
|
||||
//! - **Suggestions** tuned for Italian ([`suggest`]): missing accents first (`caffe` → `caffè`,
|
||||
//! `perche` → `perché`), then the dictionary's `REP` confusions, a missing space, one-letter
|
||||
//! typos, and a dictionary scan for anything further off.
|
||||
//! - **Grammar** ([`grammar`]): cheap, reliable rules with Italian messages.
|
||||
|
||||
pub mod grammar;
|
||||
mod suggest;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_dict;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::affix::AffixDict;
|
||||
|
||||
pub use suggest::suggest;
|
||||
|
||||
/// The Italian dictionary: `None` until first use, then the installed one (or an empty one).
|
||||
static DICT: RwLock<Option<Arc<AffixDict>>> = RwLock::new(None);
|
||||
|
||||
/// File names of an Italian Hunspell dictionary (Italy first, then Switzerland).
|
||||
const NAMES: &[&str] = &["it_IT", "it_CH", "it-IT", "it"];
|
||||
|
||||
/// The Italian dictionary: the installed Hunspell `it_IT` (read on first use), one given with
|
||||
/// [`install_dictionary`], or an empty one when none is available (Italian spelling is off
|
||||
/// then; see [`has_dictionary`]).
|
||||
pub fn dictionary() -> Arc<AffixDict> {
|
||||
if let Some(d) = DICT.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = Arc::new(load_installed());
|
||||
let mut slot = DICT.write().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread (or an install) may have filled it meanwhile: that one wins.
|
||||
slot.get_or_insert(loaded).clone()
|
||||
}
|
||||
|
||||
fn load_installed() -> AffixDict {
|
||||
let Some((aff, dic)) = crate::hunspell::find(NAMES) else {
|
||||
log::info!("Italian spelling: no Hunspell it_IT dictionary installed");
|
||||
return AffixDict::default();
|
||||
};
|
||||
crate::hunspell::load(&aff, &dic).unwrap_or_else(|e| {
|
||||
log::warn!("Italian spelling: {e}");
|
||||
AffixDict::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Use this Italian dictionary from now on (Hunspell `.aff` and `.dic` text, decoded to
|
||||
/// UTF-8), for example one the user picked. On error the current dictionary stays.
|
||||
pub fn install_dictionary(aff: &str, dic: &str) -> Result<(), String> {
|
||||
let d = AffixDict::from_hunspell(aff, dic, &[])?;
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the loaded dictionary, so the next check looks for an installed one again (after
|
||||
/// [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed).
|
||||
pub fn reload_dictionary() {
|
||||
*DICT.write().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Is an Italian dictionary available (spelling is checked)?
|
||||
pub fn has_dictionary() -> bool {
|
||||
!dictionary().is_empty()
|
||||
}
|
||||
|
||||
/// Is `c` a letter of the Latin script (the only one the Italian dictionary covers)?
|
||||
fn is_latin_letter(c: char) -> bool {
|
||||
c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c))
|
||||
}
|
||||
|
||||
/// Proclitics (and verb clippings) that elide before a vowel or `h`: the word after the
|
||||
/// apostrophe is what gets checked (`l'amico`, `un'idea`, `c'è`, `m'hai`).
|
||||
const PROCLITICS: &[&str] = &[
|
||||
"l", "un", "una", "uno", "all", "dall", "dell", "nell", "sull", "coll", "pell", "c", "m", "t", "s", "v", "d", "j", "quest", "quell", "bell",
|
||||
"sant", "grand", "poc",
|
||||
];
|
||||
|
||||
/// Is `word` spelled correctly in Italian? Numbers, single letters, short words in capitals
|
||||
/// (acronyms: `RAI`, `TG`), words with digits and words in other scripts are accepted, and so
|
||||
/// is every word when no Italian dictionary is installed.
|
||||
pub fn is_correct(word: &str) -> bool {
|
||||
is_correct_with(&dictionary(), word)
|
||||
}
|
||||
|
||||
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
|
||||
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
|
||||
if d.is_empty() {
|
||||
return true; // no Italian dictionary installed: no spelling marks
|
||||
}
|
||||
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
|
||||
let w = w.trim_matches(['\'', '’']);
|
||||
let n = w.chars().count();
|
||||
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
|
||||
return true;
|
||||
}
|
||||
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
|
||||
return true;
|
||||
}
|
||||
if !w.chars().any(is_latin_letter) {
|
||||
return true; // other scripts: no dictionary
|
||||
}
|
||||
if w.contains('-') {
|
||||
return w.split('-').all(|p| p.chars().count() <= 1 || known(d, p));
|
||||
}
|
||||
if let Some((head, tail)) = w.split_once(['\'', '’'])
|
||||
&& PROCLITICS.contains(&head.to_lowercase().as_str())
|
||||
&& !tail.is_empty()
|
||||
{
|
||||
return known(d, tail);
|
||||
}
|
||||
known(d, w)
|
||||
}
|
||||
|
||||
/// Is one word (no hyphens, no apostrophe) in the dictionary or the user dictionary?
|
||||
fn known(d: &AffixDict, w: &str) -> bool {
|
||||
d.check(w) || crate::in_user_dictionary(&w.to_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dictionary_loads() {
|
||||
test_dict::install();
|
||||
let d = dictionary();
|
||||
assert!(has_dictionary());
|
||||
assert!(d.len() > 50, "{}", d.len());
|
||||
assert!(d.rule_count() >= 4, "the test dictionary's affix rules");
|
||||
assert!(!d.rep().is_empty() && !d.map().is_empty() && !d.try_chars().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swiss_dictionary_files_are_found() {
|
||||
// it_CH is searched too, so Swiss users with only it_CH installed get Italian spelling.
|
||||
let dir = std::env::temp_dir().join(format!("wc-it-ch-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
std::fs::write(dir.join("it_CH.aff"), test_dict::AFF).expect("aff");
|
||||
std::fs::write(dir.join("it_CH.dic"), test_dict::DIC).expect("dic");
|
||||
let found = crate::hunspell::find_in(&dir, NAMES);
|
||||
let ok = found.as_ref().is_some_and(|(a, d)| a.ends_with("it_CH.aff") && d.ends_with("it_CH.dic"));
|
||||
assert!(ok, "{found:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_dictionary_nothing_is_flagged() {
|
||||
let none = AffixDict::default();
|
||||
for w in ["caffè", "perché", "xqzv", ""] {
|
||||
assert!(is_correct_with(&none, w), "{w}");
|
||||
}
|
||||
assert!(install_dictionary("FLAG long\n", "1\nkot\n").is_err(), "unsupported files are refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_words_are_correct() {
|
||||
test_dict::install();
|
||||
for w in [
|
||||
"casa", "case", "libro", "libri", "città", "caffè", "perché", "più", "può", "parlare", "parla", "parlano", "rifare", "bello", "belli",
|
||||
"nuovo", "bianco", "Roma", "Milano", "l'amico", "un'idea", "L'amico", "RAI", "2026", "e", "non",
|
||||
] {
|
||||
assert!(is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspellings_are_flagged() {
|
||||
test_dict::install();
|
||||
for w in ["caffé", "perche", "lbiro", "parlre", "amicaa", "citta", "quelo"] {
|
||||
assert!(!is_correct(w), "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_dictionary_applies() {
|
||||
test_dict::install();
|
||||
assert!(!is_correct("zxqitalianaparola"));
|
||||
crate::add_word("Zxqitalianaparola");
|
||||
assert!(is_correct("zxqitalianaparola"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Italian spelling suggestions, best first.
|
||||
//!
|
||||
//! Candidates come in rounds of rising cost, each checked against the dictionary:
|
||||
//! 1. letter case (`roma` → `Roma`);
|
||||
//! 2. missing or wrong accents, from the dictionary's `MAP` groups (`caffe` → `caffè`,
|
||||
//! `perche` → `perché`), up to four letters changed — Italian typed without accents is the
|
||||
//! most common slip, so adding one ranks first, while taking away an accent the writer typed
|
||||
//! ranks after the known confusions;
|
||||
//! 3. the dictionary's `REP` table of common confusions (`caffé` → `caffè`);
|
||||
//! 4. a missing space (`perme` → `per me`, `amilano` → `a milano`), cheaper when one half is a
|
||||
//! short function word;
|
||||
//! 5. one typo: a letter left out, added, swapped with the next or mistyped (`TRY` letters);
|
||||
//! 6. when that finds fewer than asked for: the stems that start like the word (accents
|
||||
//! folded) and share the most letter pairs with it, whose forms are ranked by an edit distance
|
||||
//! in which an accent slip costs half (two typos at most).
|
||||
//!
|
||||
//! The input is bounded (40 letters), and so is every round, so a call costs ten-odd thousand
|
||||
//! dictionary lookups at most.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::affix::{AffixDict, Casing, capitalize, casing};
|
||||
|
||||
/// Short words often run into the next one (`per me`, `a Milano`, `di Roma`).
|
||||
const LEADING_WORDS: &[&str] = &[
|
||||
"a", "e", "è", "di", "da", "in", "con", "su", "per", "tra", "fra", "che", "chi", "cui", "non", "si", "ci", "vi", "ne", "più", "meno", "molto",
|
||||
"tanto", "come", "dove", "quando", "mai", "qui", "qua", "lì", "là", "ogni", "altro", "tutto", "questo", "quello", "bene", "male", "anche",
|
||||
"ancora", "sempre", "già", "poi", "ed", "od", "ad", "un", "uno", "una", "il", "lo", "la", "i", "gli", "le", "del", "al", "dal", "nel", "sul",
|
||||
"col", "pel", "allo", "dello", "nello", "sullo", "della", "alla", "dalla", "nella", "sulla", "degli", "agli", "dagli", "negli", "sugli", "delle",
|
||||
"alle", "dalle", "nelle", "sulle", "dei", "ai", "dai", "nei", "sui", "coi", "dei",
|
||||
];
|
||||
/// Short words often run into the one before (enclitic pronouns: `dimmelo` in writing without
|
||||
/// the doubling, or a dropped space: `perme` → `per me`).
|
||||
const TRAILING_WORDS: &[&str] = &[
|
||||
"mi", "ti", "si", "ci", "vi", "ne", "lo", "la", "li", "le", "gli", "me", "te", "lui", "lei", "noi", "voi", "loro", "che", "cosa", "come", "dove",
|
||||
"è",
|
||||
];
|
||||
const MAX_LETTERS: usize = 40;
|
||||
const MAX_MAP_CHANGES: usize = 4;
|
||||
const MAX_MAP_VARIANTS: usize = 4096;
|
||||
/// Stems the scan of round 6 expands.
|
||||
const SCAN_STEMS: usize = 48;
|
||||
|
||||
const COST_CASE: u32 = 0;
|
||||
const COST_ADD_DIACRITIC: u32 = 4;
|
||||
const COST_OTHER_MAP: u32 = 7;
|
||||
const COST_DROP_DIACRITIC: u32 = 11;
|
||||
const COST_REP: u32 = 10;
|
||||
const COST_SPLIT: u32 = 12;
|
||||
const COST_SPLIT_RARE: u32 = 18;
|
||||
const COST_SWAP: u32 = 14;
|
||||
const COST_DROP_OR_ADD: u32 = 15;
|
||||
const COST_MISTYPE: u32 = 16;
|
||||
const COST_SCAN: u32 = 20;
|
||||
|
||||
struct Collector<'a> {
|
||||
d: &'a AffixDict,
|
||||
/// The word, lower case.
|
||||
input: String,
|
||||
tested: HashSet<String>,
|
||||
/// Suggestion → (cost, order found).
|
||||
found: HashMap<String, (u32, usize)>,
|
||||
seq: usize,
|
||||
}
|
||||
|
||||
impl Collector<'_> {
|
||||
/// Check a lower-case candidate (a word, or two separated by a space) and keep it if every
|
||||
/// word is in the dictionary, as written or capitalized (names).
|
||||
fn offer(&mut self, cand: &str, cost: u32) {
|
||||
if cand.is_empty() || cand == self.input || !self.tested.insert(cand.to_string()) {
|
||||
return;
|
||||
}
|
||||
let mut words = Vec::new();
|
||||
let mut cost = cost;
|
||||
for part in cand.split(' ') {
|
||||
if self.d.contains(part) {
|
||||
words.push(part.to_string());
|
||||
} else {
|
||||
let c = capitalize(part);
|
||||
if !self.d.contains(&c) {
|
||||
return;
|
||||
}
|
||||
words.push(c);
|
||||
// A name ranks after a common word at the same distance.
|
||||
cost += 1;
|
||||
}
|
||||
}
|
||||
self.keep(words.join(" "), cost);
|
||||
}
|
||||
|
||||
/// Keep a suggestion known to be correct.
|
||||
fn keep(&mut self, text: String, cost: u32) {
|
||||
self.seq += 1;
|
||||
let seq = self.seq;
|
||||
let e = self.found.entry(text).or_insert((cost, seq));
|
||||
if cost < e.0 {
|
||||
*e = (cost, seq);
|
||||
}
|
||||
}
|
||||
|
||||
fn count_within(&self, cost: u32) -> usize {
|
||||
self.found.values().filter(|(c, _)| *c <= cost).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// The MAP group of each letter (its other members are its likely confusions).
|
||||
fn alternatives(c: char, map: &[Vec<char>]) -> Vec<char> {
|
||||
map.iter().filter(|g| g.contains(&c)).flat_map(|g| g.iter().copied()).filter(|&x| x != c).collect()
|
||||
}
|
||||
|
||||
/// The cost of changing letter `from` to its MAP relative `to`. Adding a missing accent is the
|
||||
/// commonest slip (Italian typed without accents: `citta`); an accent the writer did type
|
||||
/// is likelier right, so taking one away costs more than a known confusion from the REP table.
|
||||
fn map_cost(from: char, to: char) -> u32 {
|
||||
match (from.is_ascii(), to.is_ascii()) {
|
||||
(true, false) => COST_ADD_DIACRITIC,
|
||||
(false, true) => COST_DROP_DIACRITIC,
|
||||
_ => COST_OTHER_MAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way of changing up to [`MAX_MAP_CHANGES`] letters to a MAP relative, with its cost.
|
||||
fn map_variants(chars: &[char], alts: &[Vec<char>], i: usize, changes: usize, cost: u32, buf: &mut Vec<char>, out: &mut Vec<(String, u32)>) {
|
||||
if out.len() >= MAX_MAP_VARIANTS {
|
||||
return;
|
||||
}
|
||||
let Some(&c) = chars.get(i) else {
|
||||
if changes > 0 {
|
||||
out.push((buf.iter().collect(), cost));
|
||||
}
|
||||
return;
|
||||
};
|
||||
buf.push(c);
|
||||
map_variants(chars, alts, i + 1, changes, cost, buf, out);
|
||||
buf.pop();
|
||||
if changes < MAX_MAP_CHANGES {
|
||||
for &a in alts.get(i).map(Vec::as_slice).unwrap_or_default() {
|
||||
buf.push(a);
|
||||
map_variants(chars, alts, i + 1, changes + 1, cost + map_cost(c, a), buf, out);
|
||||
buf.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter's MAP representative (the first of its group), lower case: `à` → `a`.
|
||||
fn fold(c: char, map: &[Vec<char>]) -> char {
|
||||
let l = c.to_lowercase().next().unwrap_or(c);
|
||||
map.iter().find(|g| g.contains(&l)).and_then(|g| g.first().copied()).unwrap_or(l)
|
||||
}
|
||||
|
||||
/// Optimal string alignment distance where a MAP slip (`a`/`à`, `e`/`è`) costs 1 and any other
|
||||
/// edit 2; `limit + 1` once it is certainly above `limit`.
|
||||
fn weighted_distance(a: &[char], b: &[char], related: &dyn Fn(char, char) -> bool, limit: u32) -> u32 {
|
||||
let m = b.len();
|
||||
let over = limit + 1;
|
||||
let mut prev2 = vec![0u32; m + 1];
|
||||
let mut prev: Vec<u32> = (0..=m as u32).map(|j| j * 2).collect();
|
||||
let mut cur = vec![0u32; m + 1];
|
||||
for (i, &ai) in a.iter().enumerate() {
|
||||
if let Some(c) = cur.get_mut(0) {
|
||||
*c = (i as u32 + 1) * 2;
|
||||
}
|
||||
let mut row_min = u32::MAX;
|
||||
for (j, &bj) in b.iter().enumerate() {
|
||||
let sub = if ai == bj {
|
||||
0
|
||||
} else if related(ai, bj) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let at = |v: &[u32], k: usize| v.get(k).copied().unwrap_or(u32::MAX / 4);
|
||||
let mut v = (at(&prev, j + 1) + 2).min(at(&cur, j) + 2).min(at(&prev, j) + sub);
|
||||
if i > 0 && j > 0 && a.get(i - 1) == Some(&bj) && b.get(j - 1) == Some(&ai) {
|
||||
v = v.min(at(&prev2, j - 1) + 2);
|
||||
}
|
||||
if let Some(c) = cur.get_mut(j + 1) {
|
||||
*c = v;
|
||||
}
|
||||
row_min = row_min.min(v);
|
||||
}
|
||||
if m > 0 && row_min > limit {
|
||||
return over;
|
||||
}
|
||||
std::mem::swap(&mut prev2, &mut prev);
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev.get(m).copied().unwrap_or(over).min(over)
|
||||
}
|
||||
|
||||
/// Spelling suggestions for an Italian word, best first, at most `max`, in the word's letter case.
|
||||
pub fn suggest(word: &str, max: usize) -> Vec<String> {
|
||||
suggest_in(&super::dictionary(), word, max)
|
||||
}
|
||||
|
||||
/// [`suggest`] against a given dictionary.
|
||||
pub fn suggest_in(d: &AffixDict, word: &str, max: usize) -> Vec<String> {
|
||||
let word = word.trim();
|
||||
let n = word.chars().count();
|
||||
if n == 0 || n > MAX_LETTERS || max == 0 || d.is_empty() || word.contains(char::is_whitespace) {
|
||||
return Vec::new();
|
||||
}
|
||||
let case = casing(word);
|
||||
let input = word.to_lowercase();
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let mut col = Collector { d, input: input.clone(), tested: HashSet::new(), found: HashMap::new(), seq: 0 };
|
||||
// 1. Letter case.
|
||||
if d.contains(&input) && word != input {
|
||||
col.keep(input.clone(), COST_CASE);
|
||||
} else if d.contains(&capitalize(&input)) && word != capitalize(&input) {
|
||||
col.keep(capitalize(&input), COST_CASE);
|
||||
}
|
||||
// 2. Accents and other MAP slips.
|
||||
let map = d.map();
|
||||
let alts: Vec<Vec<char>> = chars.iter().map(|&c| alternatives(c, map)).collect();
|
||||
let mut variants = Vec::new();
|
||||
map_variants(&chars, &alts, 0, 0, 0, &mut Vec::with_capacity(n), &mut variants);
|
||||
variants.sort_by_key(|v| v.1);
|
||||
for (v, cost) in &variants {
|
||||
col.offer(v, *cost);
|
||||
}
|
||||
// 3. Common confusions.
|
||||
for (from, to) in d.rep() {
|
||||
if from.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, _) in input.match_indices(from.as_str()) {
|
||||
if let (Some(a), Some(b)) = (input.get(..i), input.get(i + from.len()..)) {
|
||||
col.offer(&format!("{a}{to}{b}"), COST_REP);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. A missing space: cheap after a short leading word or before a short trailing one,
|
||||
// dearer between two longer words, never leaving a stray short half.
|
||||
for (i, _) in input.char_indices().skip(1) {
|
||||
let (Some(a), Some(b)) = (input.get(..i), input.get(i..)) else { continue };
|
||||
let long = |p: &str| p.chars().count() >= 3;
|
||||
let cost = if (LEADING_WORDS.contains(&a) && long(b)) || (long(a) && TRAILING_WORDS.contains(&b)) {
|
||||
COST_SPLIT
|
||||
} else if long(a) && long(b) {
|
||||
COST_SPLIT_RARE
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
col.offer(&format!("{a} {b}"), cost);
|
||||
}
|
||||
// 5. One typo.
|
||||
let mut letters: Vec<char> = d.try_chars().chars().filter(|c| c.is_lowercase()).collect();
|
||||
if letters.is_empty() {
|
||||
letters = "aeiortnslcdupmghfbqvàèéìòóùz".chars().collect();
|
||||
}
|
||||
let s = |v: &[char]| v.iter().collect::<String>();
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if chars.get(i) != chars.get(i + 1) {
|
||||
let mut v = chars.clone();
|
||||
v.swap(i, i + 1);
|
||||
col.offer(&s(&v), COST_SWAP);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let mut v = chars.clone();
|
||||
v.remove(i);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
for i in 0..=n {
|
||||
for &c in &letters {
|
||||
let mut v = chars.clone();
|
||||
v.insert(i, c);
|
||||
col.offer(&s(&v), COST_DROP_OR_ADD);
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
for &c in &letters {
|
||||
if chars.get(i) != Some(&c) {
|
||||
let mut v = chars.clone();
|
||||
if let Some(x) = v.get_mut(i) {
|
||||
*x = c;
|
||||
}
|
||||
col.offer(&s(&v), COST_MISTYPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 6. Further off: scan the stems that start like the word.
|
||||
if col.count_within(COST_MISTYPE) < max {
|
||||
scan(&mut col, &chars);
|
||||
}
|
||||
let mut found: Vec<(String, (u32, usize))> = col.found.into_iter().collect();
|
||||
// Among equal costs, the change nearest the word's start is the likelier intent.
|
||||
let diff_at = |text: &str| chars.iter().zip(text.chars()).position(|(a, b)| *a != b).unwrap_or(n);
|
||||
found.sort_by_key(|(text, (cost, seq))| (*cost, diff_at(text), *seq));
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (text, _) in found {
|
||||
let t = match case {
|
||||
Casing::Initial => capitalize(&text),
|
||||
Casing::Upper if n > 1 => text.to_uppercase(),
|
||||
_ => text,
|
||||
};
|
||||
if t != word && !out.contains(&t) {
|
||||
out.push(t);
|
||||
}
|
||||
if out.len() >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Round 6: rank the stems starting with the word's first letter (or a MAP relative of it, either
|
||||
/// case) by shared letter pairs, then keep the forms of the best within two typos.
|
||||
fn scan(col: &mut Collector, chars: &[char]) {
|
||||
let d = col.d;
|
||||
let map = d.map();
|
||||
let Some(&first) = chars.first() else { return };
|
||||
let folded: Vec<char> = chars.iter().map(|&c| fold(c, map)).collect();
|
||||
let pairs: HashSet<(char, char)> = std::iter::once(' ').chain(folded.iter().copied()).zip(folded.iter().copied()).collect();
|
||||
let mut firsts: Vec<char> = std::iter::once(first).chain(alternatives(first, map)).collect();
|
||||
let upper: Vec<char> = firsts.iter().flat_map(|c| c.to_uppercase()).collect();
|
||||
firsts.extend(upper);
|
||||
firsts.sort_unstable();
|
||||
firsts.dedup();
|
||||
let n = folded.len();
|
||||
let mut best: Vec<(u32, &str)> = Vec::new();
|
||||
for f in firsts {
|
||||
let prefix = f.to_string();
|
||||
for stem in d.stems_with_prefix(&prefix) {
|
||||
let sf: Vec<char> = stem.chars().map(|c| fold(c, map)).collect();
|
||||
let shared = std::iter::once(' ').chain(sf.iter().copied()).zip(sf.iter().copied()).filter(|p| pairs.contains(p)).count();
|
||||
if shared < 2 {
|
||||
continue;
|
||||
}
|
||||
// Shared pairs, less the stem's letters the word doesn't explain, less a length gap
|
||||
// beyond what an ending explains.
|
||||
let extra = sf.len().saturating_sub(shared);
|
||||
let gap = sf.len().abs_diff(n).saturating_sub(4);
|
||||
let score = (shared * 4).saturating_sub(extra * 3 + gap * 2) as u32;
|
||||
if score == 0 {
|
||||
continue;
|
||||
}
|
||||
if best.len() < SCAN_STEMS {
|
||||
best.push((score, stem));
|
||||
} else if let Some(min) = best.iter_mut().min_by_key(|b| b.0)
|
||||
&& score > min.0
|
||||
{
|
||||
*min = (score, stem);
|
||||
}
|
||||
}
|
||||
}
|
||||
best.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
|
||||
let related = |a: char, b: char| fold(a, map) == fold(b, map);
|
||||
let limit = 4;
|
||||
for (_, stem) in best {
|
||||
for form in d.forms(stem) {
|
||||
let lf: Vec<char> = form.to_lowercase().chars().collect();
|
||||
if lf.len().abs_diff(chars.len()) > 2 {
|
||||
continue;
|
||||
}
|
||||
let dist = weighted_distance(&lf, chars, &related, limit);
|
||||
if dist <= limit && lf.as_slice() != chars {
|
||||
col.keep(form, COST_SCAN + dist * 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn has(word: &str, want: &str) {
|
||||
let s = suggest(word, 8);
|
||||
assert!(s.iter().any(|x| x == want), "{word} → {s:?}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_misspellings_get_the_right_suggestion() {
|
||||
crate::it::test_dict::install();
|
||||
has("caffe", "caffè");
|
||||
has("perche", "perché");
|
||||
has("cioe", "cioè");
|
||||
has("citta", "città");
|
||||
has("lbiro", "libro");
|
||||
has("parlre", "parlare");
|
||||
has("quelo", "quello");
|
||||
has("perme", "per me");
|
||||
has("amilano", "a Milano");
|
||||
has("roma", "Roma");
|
||||
has("Caffe", "Caffè");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_likeliest_fix_comes_first() {
|
||||
crate::it::test_dict::install();
|
||||
assert_eq!(suggest("caffe", 3).first().map(String::as_str), Some("caffè"));
|
||||
assert_eq!(suggest("perche", 3).first().map(String::as_str), Some("perché"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases_stay_empty_or_bounded() {
|
||||
crate::it::test_dict::install();
|
||||
assert!(suggest("", 5).is_empty());
|
||||
assert!(suggest("bene", 0).is_empty());
|
||||
assert!(suggest(&"a".repeat(41), 5).is_empty());
|
||||
assert!(suggest("due parole", 5).is_empty());
|
||||
assert!(suggest_in(&crate::affix::AffixDict::default(), "caffe", 5).is_empty());
|
||||
assert!(suggest("caffè", 5).len() <= 5);
|
||||
assert!(!suggest("caffè", 5).contains(&"caffè".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_weighs_accents_half() {
|
||||
let map = vec![vec!['a', 'à'], vec!['e', 'é', 'è']];
|
||||
let related = |a: char, b: char| fold(a, &map) == fold(b, &map);
|
||||
let c = |s: &str| s.chars().collect::<Vec<_>>();
|
||||
assert_eq!(weighted_distance(&c("caffe"), &c("caffè"), &related, 9), 1);
|
||||
assert_eq!(weighted_distance(&c("citta"), &c("città"), &related, 9), 1);
|
||||
assert_eq!(weighted_distance(&c("bene"), &c("bene"), &related, 9), 0);
|
||||
assert_eq!(weighted_distance(&c("bene"), &c("male"), &related, 2), 3);
|
||||
assert_eq!(weighted_distance(&c("bene"), &c("benissimo"), &related, 2), 3);
|
||||
assert_eq!(weighted_distance(&c(""), &c("ab"), &related, 9), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestions_are_fast_enough() {
|
||||
crate::it::test_dict::install();
|
||||
// Warm the dictionary, then time a worst case (a long word nothing near): the scan round.
|
||||
let _ = suggest("bene", 1);
|
||||
let t = std::time::Instant::now();
|
||||
for w in ["xqzvweitalianaparola", "prsidenti", "parallellepipedo"] {
|
||||
let _ = suggest(w, 6);
|
||||
}
|
||||
let ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
eprintln!("3 suggestion calls: {ms:.0} ms");
|
||||
assert!(ms < 20_000.0, "{ms} ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! A tiny synthetic Italian Hunspell dictionary for tests (written for these tests; not taken
|
||||
//! from any real dictionary). Real dictionaries are installed on the system, never bundled.
|
||||
//!
|
||||
//! It exercises what the Italian dictionaries use: the default single-character `FLAG` type,
|
||||
//! suffixes (verb endings `-are`, noun plurals `-o` → `-i`), a prefix (`ri-`), the suggestion
|
||||
//! tables (`TRY`, `MAP` for accents, `REP` for common confusions) and elided forms with an
|
||||
//! apostrophe (`l'amico`).
|
||||
|
||||
pub const AFF: &str = "SET UTF-8
|
||||
TRY aeiortnslcdupmghfbqvàèéìòóùz
|
||||
MAP 5
|
||||
MAP aà
|
||||
MAP eéè
|
||||
MAP iì
|
||||
MAP oòó
|
||||
MAP uù
|
||||
REP 3
|
||||
REP caffé caffè
|
||||
REP perche perché
|
||||
REP cioe cioè
|
||||
SFX A Y 3
|
||||
SFX A 0 are .
|
||||
SFX A 0 a .
|
||||
SFX A 0 ano .
|
||||
SFX N Y 2
|
||||
SFX N o i o
|
||||
SFX N a e a
|
||||
PFX R Y 1
|
||||
PFX R 0 ri .
|
||||
";
|
||||
|
||||
/// Stems (the count line first, as in Hunspell files).
|
||||
pub const DIC: &str = "96
|
||||
città
|
||||
caffè
|
||||
perché
|
||||
più
|
||||
può
|
||||
è
|
||||
già
|
||||
così
|
||||
virtù
|
||||
tribù
|
||||
menù
|
||||
tè
|
||||
Mosca
|
||||
Roma
|
||||
Milano
|
||||
Torino
|
||||
casa/N
|
||||
libro/N
|
||||
cane
|
||||
gatto
|
||||
sole
|
||||
luna
|
||||
mare
|
||||
amico
|
||||
amica
|
||||
idea
|
||||
parl/A
|
||||
mangiare
|
||||
dormire
|
||||
fare/R
|
||||
scuola
|
||||
bambino/N
|
||||
albero
|
||||
fiore
|
||||
pane
|
||||
acqua
|
||||
terra
|
||||
cielo
|
||||
notte
|
||||
giorno
|
||||
vecchia
|
||||
vado
|
||||
con
|
||||
ha
|
||||
ho
|
||||
bel
|
||||
cioè
|
||||
la
|
||||
un
|
||||
in
|
||||
piazza
|
||||
lavoro
|
||||
tempo
|
||||
uomo
|
||||
donna
|
||||
bello/N
|
||||
buono
|
||||
grande
|
||||
piccolo
|
||||
nuovo/N
|
||||
vecchio
|
||||
bianco
|
||||
nero
|
||||
rosso
|
||||
verde
|
||||
giallo
|
||||
uno
|
||||
due
|
||||
tre
|
||||
tutto
|
||||
come
|
||||
quando
|
||||
dove
|
||||
senza
|
||||
sopra
|
||||
sotto
|
||||
tra
|
||||
molto
|
||||
sempre
|
||||
mai
|
||||
qui
|
||||
lì
|
||||
sì
|
||||
no
|
||||
non
|
||||
che
|
||||
chi
|
||||
cui
|
||||
mio
|
||||
tuo
|
||||
suo
|
||||
nostro
|
||||
a
|
||||
per
|
||||
me
|
||||
quello
|
||||
";
|
||||
|
||||
/// Make [`AFF`]/[`DIC`] the Italian dictionary (every test that needs one calls this; they all
|
||||
/// install the same one, so tests running in parallel agree).
|
||||
pub fn install() {
|
||||
if let Err(e) = super::install_dictionary(AFF, DIC) {
|
||||
panic!("test dictionary: {e}");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ pub enum ProofLang {
|
||||
/// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation
|
||||
/// patterns.
|
||||
Pt,
|
||||
/// Italian (`it`, `it-IT`, `it-CH`): the installed Hunspell dictionary (`it_IT`, when there
|
||||
/// is one), the Italian grammar rules and the Italian hyphenation patterns.
|
||||
It,
|
||||
/// Croatian (`hr`, `hr-HR`)
|
||||
Hr,
|
||||
/// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without
|
||||
/// the language's proofing tools: no spelling or grammar marks, so its words are never
|
||||
/// flagged as English misspellings. Hyphenation keeps using the English patterns, as before
|
||||
@@ -30,7 +35,7 @@ pub enum ProofLang {
|
||||
|
||||
impl ProofLang {
|
||||
/// Every language with a spelling dictionary, in display order.
|
||||
pub const PROOFED: [ProofLang; 3] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt];
|
||||
pub const PROOFED: [ProofLang; 5] = [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr];
|
||||
|
||||
/// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). Only the primary subtag
|
||||
/// counts. No tag (or an empty one) is English, WordCraft's default editing language.
|
||||
@@ -45,6 +50,10 @@ impl ProofLang {
|
||||
ProofLang::Bg
|
||||
} else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") {
|
||||
ProofLang::Pt
|
||||
} else if primary.eq_ignore_ascii_case("it") || primary.eq_ignore_ascii_case("ita") {
|
||||
ProofLang::It
|
||||
} else if primary.eq_ignore_ascii_case("hr") || primary.eq_ignore_ascii_case("hrv") {
|
||||
ProofLang::Hr
|
||||
} else {
|
||||
ProofLang::Other
|
||||
}
|
||||
@@ -57,6 +66,8 @@ impl ProofLang {
|
||||
ProofLang::Pl => "pl",
|
||||
ProofLang::Bg => "bg",
|
||||
ProofLang::Pt => "pt",
|
||||
ProofLang::It => "it",
|
||||
ProofLang::Hr => "hr",
|
||||
ProofLang::Other => "",
|
||||
}
|
||||
}
|
||||
@@ -133,6 +144,9 @@ mod tests {
|
||||
for t in ["pt", "pt-BR", "PT_br", "pt-BR.UTF-8", " pt-PT ", "por"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Pt, "{t}");
|
||||
}
|
||||
for t in ["it", "it-IT", "IT_it", "it-CH", "it-CH.UTF-8", " it-IT ", "ita"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::It, "{t}");
|
||||
}
|
||||
for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] {
|
||||
assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}");
|
||||
}
|
||||
@@ -152,6 +166,8 @@ mod tests {
|
||||
assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed());
|
||||
assert_eq!(ProofLang::Pl.code(), "pl");
|
||||
assert_eq!(ProofLang::Pt.code(), "pt");
|
||||
assert!(ProofLang::It.is_proofed());
|
||||
assert_eq!(ProofLang::It.code(), "it");
|
||||
assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed");
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -18,6 +18,11 @@
|
||||
//! dictionary installed on the system (VERO and friends; not bundled), suggestions tuned for
|
||||
//! Portuguese (missing accents, `seje`/`seja` confusions, missing spaces), conservative
|
||||
//! grammar rules with Portuguese messages, and `hyph-utf8`'s Portuguese patterns.
|
||||
//! - **Italian** ([`ProofLang::It`], module [`it`]): spelling against the Hunspell `it_IT`
|
||||
//! dictionary installed on the system (not bundled — the upstream file is GPL-licensed),
|
||||
//! suggestions tuned for Italian (missing accents, `REP` confusions, missing spaces),
|
||||
//! conservative grammar rules with Italian messages, and `hyph-utf8`'s Italian patterns
|
||||
//! (Beccari, MIT option).
|
||||
//! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without
|
||||
//! the language's proofing tools.
|
||||
//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese),
|
||||
@@ -29,8 +34,10 @@
|
||||
pub mod affix;
|
||||
pub mod bg;
|
||||
pub mod dict;
|
||||
pub mod hr;
|
||||
pub mod hunspell;
|
||||
pub mod hyphen;
|
||||
pub mod it;
|
||||
pub mod lang;
|
||||
pub mod patterns;
|
||||
pub mod pl;
|
||||
@@ -498,7 +505,9 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option
|
||||
}
|
||||
let message = match lang {
|
||||
ProofLang::Pl => "Możliwy błąd pisowni",
|
||||
ProofLang::Hr => "Moguća pravopisna pogreška",
|
||||
ProofLang::Pt => "Possível erro de ortografia",
|
||||
ProofLang::It => "Possibile errore di ortografia",
|
||||
_ => "Possible spelling mistake",
|
||||
};
|
||||
Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang })
|
||||
@@ -514,7 +523,9 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
|
||||
match lang {
|
||||
ProofLang::En => is_correct_with(word, opts),
|
||||
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word),
|
||||
ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct(word),
|
||||
ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word),
|
||||
ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word),
|
||||
ProofLang::Bg | ProofLang::Other => true,
|
||||
}
|
||||
}
|
||||
@@ -524,19 +535,23 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec<String> {
|
||||
match lang {
|
||||
ProofLang::En => suggest(word, max),
|
||||
ProofLang::Pl => pl::suggest(word, max),
|
||||
ProofLang::Hr => hr::suggest(word, max),
|
||||
ProofLang::Pt => pt::suggest(word, max),
|
||||
ProofLang::It => it::suggest(word, max),
|
||||
ProofLang::Bg | ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones
|
||||
/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), or none for a language WordCraft
|
||||
/// doesn't proof.
|
||||
/// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]),
|
||||
/// or none for a language WordCraft doesn't proof.
|
||||
pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec<Issue> {
|
||||
match lang {
|
||||
ProofLang::En => check_grammar(text),
|
||||
ProofLang::Pl => pl::grammar::check(text),
|
||||
ProofLang::Hr => hr::grammar::check(text),
|
||||
ProofLang::Pt => pt::grammar::check(text),
|
||||
ProofLang::It => it::grammar::check(text),
|
||||
ProofLang::Bg | ProofLang::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -989,6 +1004,21 @@ mod tests {
|
||||
assert!(!g.iter().any(|i| i.message.contains("\"an\"")), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_uses_its_own_tools() {
|
||||
it::test_dict::install();
|
||||
let it = "La città vecchia ha un bel caffè in piazza.";
|
||||
assert!(check_spelling_in(it, ProofLang::It).is_empty(), "{:?}", check_spelling_in(it, ProofLang::It));
|
||||
assert!(check_spelling_in(it, ProofLang::En).len() >= 3, "Italian words are English misspellings");
|
||||
let v = check_spelling_in("Vado a Roma con lbiro.", ProofLang::It);
|
||||
assert_eq!(flagged("Vado a Roma con lbiro.", &v), ["lbiro"]);
|
||||
assert_eq!((v[0].lang, v[0].message.as_str()), (ProofLang::It, "Possibile errore di ortografia"));
|
||||
assert!(suggest_in("caffe", 5, ProofLang::It).contains(&"caffè".to_string()));
|
||||
// Italian grammar speaks Italian and has no English a/an rule.
|
||||
let g = check_grammar_in("questo questo è un test", ProofLang::It);
|
||||
assert!(g.iter().any(|i| i.message.contains("ripetuta")), "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_language_paragraphs_check_each_run_in_its_language() {
|
||||
pl::test_dict::install();
|
||||
@@ -1014,6 +1044,12 @@ mod tests {
|
||||
let v = check_text(t, &[(0..t.len(), ProofLang::Pt)]);
|
||||
assert_eq!(flagged(t, &v), ["coraçao", "frequencia"], "{v:?}");
|
||||
assert!(v.iter().all(|i| i.lang == ProofLang::Pt && i.message == "Possível erro de ortografia"), "{v:?}");
|
||||
// Italian checks in its span only, with the Italian message.
|
||||
it::test_dict::install();
|
||||
let t = "Vado a Roma con lbiro e caffe.";
|
||||
let v = check_text(t, &[(0..t.len(), ProofLang::It)]);
|
||||
assert_eq!(flagged(t, &v), ["lbiro", "caffe"], "{v:?}");
|
||||
assert!(v.iter().all(|i| i.lang == ProofLang::It && i.message == "Possibile errore di ortografia"), "{v:?}");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
@@ -1025,7 +1061,7 @@ mod tests {
|
||||
let _ = has_dictionary(Some(&s));
|
||||
let _ = is_correct_for(&s, Some(&s));
|
||||
let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default());
|
||||
for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::Other] {
|
||||
for lang in [ProofLang::En, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Other] {
|
||||
let _ = check_spelling_in(&s, lang);
|
||||
let _ = check_grammar_in(&s, lang);
|
||||
let _ = is_correct_in(&s, lang);
|
||||
|
||||
@@ -200,6 +200,24 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()
|
||||
}
|
||||
|
||||
/// The natural size in points of a Windows Metafile or Enhanced Metafile picture, and the file
|
||||
/// extension it is stored under (`"wmf"` or `"emf"`), or None for anything else (#802). Very large
|
||||
/// frames are scaled down to 22 inches on the longer side, keeping the shape.
|
||||
pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
|
||||
if !wordcraft_metafile::is_metafile(bytes) {
|
||||
return None;
|
||||
}
|
||||
let pic = wordcraft_metafile::parse(bytes).ok()?;
|
||||
let (w, h) = (pic.width, pic.height);
|
||||
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let k = (1584.0 / w.max(h)).min(1.0);
|
||||
// An EMF starts with its header record, type 1.
|
||||
let ext = if bytes.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
|
||||
Some((w * k, h * k, ext))
|
||||
}
|
||||
|
||||
/// A metafile (WMF/EMF) picture, parsed once. The geometry comes placed by the metafile crate; the
|
||||
/// bitmaps are pixmaps, one per picture item (None for paths and undecodable bitmaps).
|
||||
struct Vector {
|
||||
@@ -412,6 +430,95 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine,
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
/// The on-screen colour behind page point (`x`, `y`): the paper (page colour), then any
|
||||
/// shading, highlight or filled shape drawn over that point, blended in drawing order. Text,
|
||||
/// pictures and turned or rotated content are left out. The caret takes its colour from this
|
||||
/// so it shows against whatever lies behind it (#299).
|
||||
pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, y: f32) -> Rgb {
|
||||
let paper = opts.ink(doc.settings.page_color.unwrap_or(opts.paper));
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
return paper;
|
||||
}
|
||||
let mut bg = [paper.0 as f32, paper.1 as f32, paper.2 as f32];
|
||||
let items = page_display(doc, page, &opts.display);
|
||||
blend_backgrounds(&items, wordcraft_geom::Point::new(x, y), opts, &mut bg, 0);
|
||||
let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
|
||||
Rgb(c(bg[0]), c(bg[1]), c(bg[2]))
|
||||
}
|
||||
|
||||
fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
for it in items {
|
||||
let (color, alpha) = match it {
|
||||
Draw::Fill { rect, color, alpha } if rect.contains(p) => (*color, *alpha),
|
||||
// The shape's own outline (an ellipse's corners are not inside it). Shape fills are
|
||||
// drawn opaque (the model has no fill transparency), so they cover what's beneath.
|
||||
Draw::Shape { rect, kind, fill: Some(fill), .. }
|
||||
if *kind != ShapeKind::Line && rect.contains(p) && {
|
||||
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
|
||||
kurbo::Shape::contains(&shape_path(*kind, r), kurbo::Point::new(p.x as f64, p.y as f64))
|
||||
} =>
|
||||
{
|
||||
(*fill, 1.0)
|
||||
}
|
||||
Draw::Clip { rect, items } if rect.contains(p) => {
|
||||
blend_backgrounds(items, p, opts, bg, depth + 1);
|
||||
continue;
|
||||
}
|
||||
Draw::Figure { draws, .. } => {
|
||||
blend_backgrounds(draws, p, opts, bg, depth + 1);
|
||||
continue;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let a = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 };
|
||||
let c = opts.ink(color);
|
||||
for (v, n) in bg.iter_mut().zip([c.0, c.1, c.2]) {
|
||||
*v += (n as f32 - *v) * a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of `text` in `face` at `size` points, black on white, in a `w`×`h` point raster at
|
||||
/// `scale` pixels per point, starting `margin` points in with the baseline `margin` + the face's
|
||||
/// ascent down: a font list entry. Outlines come straight from the face, not the shared outline
|
||||
/// cache, so a face read just for the preview leaves nothing behind once dropped (#848).
|
||||
pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32, w: f32, h: f32, margin: f32, scale: f32) -> Rendered {
|
||||
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
|
||||
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
|
||||
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64));
|
||||
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
|
||||
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
|
||||
let k = size / upem;
|
||||
let view = Affine::scale(scale as f64);
|
||||
let ascent = if face.ascent.is_finite() { face.ascent.clamp(0.0, upem * 4.0) } else { upem * 0.8 };
|
||||
let baseline = margin + ascent * k;
|
||||
let mut x = margin;
|
||||
for g in wordcraft_fonts::shape(face, text, &[], |c| c) {
|
||||
let o = face.glyph_outline(g.gid);
|
||||
ctx.set_transform(view * Affine::translate((x + g.x_offset as f64 * k, baseline - g.y_offset as f64 * k)) * Affine::scale(k));
|
||||
ctx.fill_path(&o);
|
||||
x += g.x_advance as f64 * k;
|
||||
if x > w as f64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
fn draw_watermark(ctx: &mut RenderContext, view: Affine, page: &Page, wm: &wordcraft_doc::Watermark, opts: &RenderOptions) {
|
||||
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
|
||||
let face = r.face;
|
||||
@@ -884,11 +991,83 @@ pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32,
|
||||
render_region(doc, page, pw, ph, view, opts)
|
||||
}
|
||||
|
||||
/// Draw `items` (page points) alone, on nothing, over the page area (`x`, `y`, `w`×`h` points)
|
||||
/// at `scale` px/pt: no paper, no watermark. A dragged object's preview.
|
||||
pub fn render_items(doc: &Document, items: &[Draw], x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let w16 = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
|
||||
let h16 = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
|
||||
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
|
||||
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
|
||||
for it in items {
|
||||
draw(&mut ctx, doc, it, view, &visible, opts);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w16 as usize * h16 as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w16, h16, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
|
||||
|
||||
/// #299: the caret's background is the page colour, a highlight, or the dark page.
|
||||
#[test]
|
||||
fn background_at_caret_follows_page_colour_highlight_and_dark_mode() {
|
||||
use wordcraft_doc::Pos;
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let mut d = Document::from_text("Hello WordCraft");
|
||||
let mid = |d: &Document, off: usize| {
|
||||
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
|
||||
let c = l.caret_on(&Pos::body(0, off), 0).unwrap();
|
||||
let page = l.pages[c.page].clone();
|
||||
(page, c.x, c.top + c.height / 2.0)
|
||||
};
|
||||
let light = RenderOptions::default();
|
||||
let dark = RenderOptions { dark: true, ..Default::default() };
|
||||
let (page, x, y) = mid(&d, 2);
|
||||
assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE);
|
||||
assert_eq!(background_at(&d, &page, &dark, x, y), Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER));
|
||||
assert_eq!(background_at(&d, &page, &light, f32::NAN, f32::INFINITY), Rgb::WHITE, "junk points fall back to the paper");
|
||||
d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40));
|
||||
assert_eq!(background_at(&d, &page, &light, x, y), Rgb(0x10, 0x10, 0x40));
|
||||
d.settings.page_color = None;
|
||||
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| c.highlight = Some(Highlight::Black)).unwrap();
|
||||
let (page, x, y) = mid(&d, 2);
|
||||
assert_eq!(background_at(&d, &page, &light, x, y), Rgb::BLACK, "inside the highlight");
|
||||
let (page, x, y) = mid(&d, 9);
|
||||
assert_eq!(background_at(&d, &page, &light, x, y), Rgb::WHITE, "past the highlight");
|
||||
}
|
||||
|
||||
/// #299: a filled shape colours the caret's background only inside its outline: an
|
||||
/// ellipse's corner shows what lies beneath, its centre the fill.
|
||||
#[test]
|
||||
fn background_follows_a_shapes_outline_not_its_box() {
|
||||
let opts = RenderOptions::default();
|
||||
let ellipse = Draw::Shape {
|
||||
rect: wordcraft_geom::Rect::new(100.0, 100.0, 200.0, 100.0),
|
||||
kind: ShapeKind::Ellipse,
|
||||
fill: Some(Rgb::BLACK),
|
||||
stroke: None,
|
||||
stroke_width: 0.0,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let at = |x: f32, y: f32| {
|
||||
let mut bg = [255.0; 3];
|
||||
blend_backgrounds(std::slice::from_ref(&ellipse), wordcraft_geom::Point::new(x, y), &opts, &mut bg, 0);
|
||||
bg
|
||||
};
|
||||
assert_eq!(at(200.0, 150.0), [0.0; 3], "the centre is the fill");
|
||||
assert_eq!(at(103.0, 103.0), [255.0; 3], "the box's corner is outside the ellipse");
|
||||
assert_eq!(at(350.0, 150.0), [255.0; 3], "outside the box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_text_dark_pixels() {
|
||||
let d = Document::from_text("Hello WordCraft");
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Responsiveness probe: runs the real WordCraft UI headless (like `ui_shot`, without a window or
|
||||
//! focus), opens a document, and times the frames that handle typing, scrolling, zooming,
|
||||
//! ribbon tabs and window resizes. Prints numbers only (JSON), never document text.
|
||||
//!
|
||||
//! `cargo run --release -p wordcraft-ui-egui --example perf_probe -- doc.docx [block]`
|
||||
//!
|
||||
//! A frame's time is the CPU time of `WordApp::logic` + `ui` for that frame (layout, page
|
||||
//! rasterisation, widgets). What the user sees adds the wait for the next frame and the
|
||||
//! presentation on screen (up to one display refresh).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Services, WordApp};
|
||||
|
||||
static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 {
|
||||
let mut raw = std::mem::take(h.input_mut());
|
||||
h.state_mut().raw_input_hook(&mut raw);
|
||||
*h.input_mut() = raw;
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn stats(v: &[f64]) -> serde_json::Value {
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(|a, b| a.total_cmp(b));
|
||||
let at = |q: f64| s.get(((s.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
|
||||
json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: perf_probe doc.docx [block]");
|
||||
let block: Option<usize> = std::env::args().nth(2).and_then(|b| b.parse().ok());
|
||||
let t_open = Instant::now();
|
||||
let mut session = Session::new(wordcraft_doc::Document::new());
|
||||
session.run("file.open", &json!({"path": path})).expect("open");
|
||||
let open_ms = t_open.elapsed().as_secs_f64() * 1000.0;
|
||||
let app = WordApp::new(session, Services::default());
|
||||
let mut h =
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(2.0).with_max_steps(1_000_000).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
READY.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let first: Vec<f64> = (0..6).map(|_| step(&mut h)).collect();
|
||||
let mut out =
|
||||
json!({"open_ms": (open_ms * 10.0).round() / 10.0, "first_frames_ms": first.iter().map(|x| (x * 10.0).round() / 10.0).collect::<Vec<_>>()});
|
||||
|
||||
// Typing: the caret in the middle of `block` (default: the longest paragraph), 40 characters.
|
||||
let doc_block = block.unwrap_or_else(|| {
|
||||
let s = &h.state().session;
|
||||
s.doc.body.iter().enumerate().filter_map(|(i, b)| b.as_para().map(|p| (i, p.len()))).max_by_key(|(_, l)| *l).map(|(i, _)| i).unwrap_or(0)
|
||||
});
|
||||
let typing = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: serde_json::Value| -> serde_json::Value {
|
||||
h.state_mut().session.run("caret.set", &json!({"pos": pos})).ok();
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
for _ in 0..3 {
|
||||
step(h);
|
||||
}
|
||||
let mut frames = Vec::new();
|
||||
let (mut lay, mut ren) = (Vec::new(), Vec::new());
|
||||
for _ in 0..40 {
|
||||
h.input_mut().events.push(egui::Event::Text("a".into()));
|
||||
frames.push(step(h));
|
||||
// the frame after (relayout of a long paragraph may finish there)
|
||||
frames.push(step(h));
|
||||
lay.push(h.state_mut().session.layout().ms);
|
||||
ren.push(h.state().canvas.render_ms);
|
||||
}
|
||||
let typed = frames.chunks(2).map(|c| c[0] + c[1]).collect::<Vec<_>>();
|
||||
json!({"frame_with_key_ms": stats(&frames.iter().step_by(2).copied().collect::<Vec<_>>()), "two_frames_ms": stats(&typed), "layout_ms": stats(&lay), "render_ms": stats(&ren)})
|
||||
};
|
||||
let mid = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len() / 2).unwrap_or(0);
|
||||
// a char boundary at or before the middle
|
||||
let mid = {
|
||||
let p = h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.plain_text()).unwrap_or_default();
|
||||
(0..=mid.min(p.len())).rev().find(|i| p.is_char_boundary(*i)).unwrap_or(0)
|
||||
};
|
||||
out["typing_long_para"] = typing(&mut h, json!({"story": "body", "path": [doc_block], "off": mid}));
|
||||
out["typing_para_len"] = json!(h.state().session.doc.body.get(doc_block).and_then(|b| b.as_para()).map(|p| p.len()).unwrap_or(0));
|
||||
let table = h.state().session.doc.body.iter().position(|b| matches!(&**b, wordcraft_doc::Block::Table(_)));
|
||||
if let Some(t) = table {
|
||||
out["typing_table"] = typing(&mut h, json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}));
|
||||
}
|
||||
|
||||
// Scrolling: mouse wheel (120 pt notches) and trackpad (20 pt gestures) over the canvas.
|
||||
let scroll = |h: &mut egui_kittest::Harness<'_, WordApp>, dy: f32, n: usize| -> serde_json::Value {
|
||||
h.state_mut().session.run("caret.docStart", &json!({})).ok();
|
||||
for _ in 0..3 {
|
||||
step(h);
|
||||
}
|
||||
let mut f = Vec::new();
|
||||
for _ in 0..n {
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(720.0, 500.0)));
|
||||
h.input_mut().events.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, dy),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
f.push(step(h));
|
||||
}
|
||||
json!({"frame_ms": stats(&f), "frames_over_33ms": f.iter().filter(|x| **x > 33.0).count(), "frames_over_16ms": f.iter().filter(|x| **x > 16.7).count()})
|
||||
};
|
||||
out["scroll_wheel"] = scroll(&mut h, -120.0, 120);
|
||||
out["scroll_trackpad"] = scroll(&mut h, -20.0, 300);
|
||||
|
||||
// Zoom, ribbon tabs, resize: the frame after the change.
|
||||
let mut zoom = Vec::new();
|
||||
for z in [50, 75, 100, 150, 200, 100] {
|
||||
h.state_mut().session.run("view.zoom", &json!({"value": z})).ok();
|
||||
let a = step(&mut h);
|
||||
let b = step(&mut h);
|
||||
zoom.push(json!({"zoom": z, "frame_ms": (a * 10.0).round() / 10.0, "next_ms": (b * 10.0).round() / 10.0}));
|
||||
}
|
||||
out["zoom"] = json!(zoom);
|
||||
let mut tabs = Vec::new();
|
||||
for t in ["Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Home"] {
|
||||
h.state_mut().execute("ui.tab", json!({"tab": t})).ok();
|
||||
tabs.push(step(&mut h));
|
||||
}
|
||||
out["ribbon_tab_ms"] = stats(&tabs);
|
||||
let mut rs = Vec::new();
|
||||
for w in (800..=1440).rev().step_by(40).chain((800..=1440).step_by(40)) {
|
||||
h.set_size(egui::vec2(w as f32, 900.0));
|
||||
rs.push(step(&mut h));
|
||||
}
|
||||
out["resize_ms"] = stats(&rs);
|
||||
// Accept all (if the document has tracked changes).
|
||||
if !h.state().session.doc.revisions.is_empty() {
|
||||
let t = Instant::now();
|
||||
h.state_mut().session.run("review.acceptAll", &json!({})).ok();
|
||||
let cmd = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let f = step(&mut h);
|
||||
out["accept_all"] = json!({"command_ms": (cmd * 10.0).round() / 10.0, "frame_ms": (f * 10.0).round() / 10.0});
|
||||
}
|
||||
println!("{out}");
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Middle-click scrolling of the document canvas (#1065), the same gesture as Word on Windows
|
||||
//! and pdfcraft's viewer: click the wheel and a marker shows where; moving the pointer above or
|
||||
//! below it scrolls, faster the farther away. Another click, Escape, any other key, the wheel or
|
||||
//! leaving the window stops it. Holding the wheel down and moving scrolls too, and then letting
|
||||
//! go stops.
|
||||
//!
|
||||
//! WordCraft doesn't paste the primary selection on a middle click, so on Linux the button is
|
||||
//! free for this. macOS apps don't scroll this way, and browsers have their own middle-click
|
||||
//! behaviour, so there the button is left alone.
|
||||
|
||||
use egui::{CursorIcon, Event, Key, PointerButton, Pos2, Stroke, Vec2, vec2};
|
||||
|
||||
/// Where the gesture exists at all (see the module docs).
|
||||
const SUPPORTED: bool = !cfg!(any(target_os = "macos", target_os = "ios", target_arch = "wasm32"));
|
||||
|
||||
/// Pointer distance (screen points) around the anchor that doesn't scroll.
|
||||
const DEAD_ZONE: f32 = 15.0;
|
||||
// Chromium's autoscroll_controller.cc uses distance^2.2 * 0.000008; its
|
||||
// ui/events/gestures/fixed_velocity_curve.cc multiplies elapsed seconds by 5000.
|
||||
const SPEED_EXPONENT: f32 = 2.2;
|
||||
const SPEED_MULTIPLIER: f32 = 0.04;
|
||||
// Bound hostile coordinates before exponentiation, far beyond ordinary screen distances.
|
||||
const MAX_DISPLACEMENT: f32 = 1_000_000.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AutoScroll {
|
||||
/// Where the wheel was clicked; `Some` while scrolling.
|
||||
anchor: Option<Pos2>,
|
||||
/// The press that started it is still down…
|
||||
holding: bool,
|
||||
/// …and the pointer has left the dead zone since: letting go ends it.
|
||||
dragged: bool,
|
||||
/// Own a stopping click through its release, so it can't also move the caret.
|
||||
cancel_button: Option<PointerButton>,
|
||||
block_input: bool,
|
||||
pub(crate) supported: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoScroll {
|
||||
fn default() -> Self {
|
||||
Self { anchor: None, holding: false, dragged: false, cancel_button: None, block_input: false, supported: SUPPORTED }
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoScroll {
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.anchor.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn cancel(&mut self) {
|
||||
self.anchor = None;
|
||||
self.holding = false;
|
||||
self.dragged = false;
|
||||
self.cancel_button = None;
|
||||
self.block_input = false;
|
||||
}
|
||||
|
||||
/// Mouse buttons belong to the gesture this frame: the canvas mustn't act on them.
|
||||
pub(crate) fn blocks_input(&self) -> bool {
|
||||
self.block_input
|
||||
}
|
||||
|
||||
/// Run before the canvas scrolls. Returns this frame's delta (positive moves the content
|
||||
/// down, as the canvas's wheel delta). Starting is restricted to the visible canvas; once
|
||||
/// started, moving outside it still controls the speed.
|
||||
pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect) -> Vec2 {
|
||||
if !self.supported {
|
||||
self.cancel();
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let ctx = ui.ctx();
|
||||
let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| {
|
||||
let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2]
|
||||
.into_iter()
|
||||
.find(|button| i.pointer.button_pressed(*button));
|
||||
(
|
||||
i.pointer.hover_pos(),
|
||||
// The press event itself: later movement in this frame mustn't move the anchor.
|
||||
i.events.iter().find_map(|event| match event {
|
||||
Event::PointerButton { pos, button: PointerButton::Middle, pressed: true, .. } if pos.is_finite() => Some(*pos),
|
||||
_ => None,
|
||||
}),
|
||||
i.pointer.button_down(PointerButton::Middle),
|
||||
i.pointer.button_released(PointerButton::Middle),
|
||||
!i.focused
|
||||
|| cancel_button.is_some()
|
||||
|| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. } | Event::MouseWheel { .. } | Event::Zoom(_))),
|
||||
cancel_button,
|
||||
i.stable_dt,
|
||||
)
|
||||
});
|
||||
self.block_input = self.active() || self.cancel_button.is_some() || middle_press.is_some() || middle_down || middle_released;
|
||||
if let Some(button) = self.cancel_button {
|
||||
if !ctx.input(|i| i.pointer.button_down(button)) {
|
||||
self.cancel_button = None;
|
||||
}
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
if self.active() && (cancel || pointer.is_none()) {
|
||||
// Escape only stops scrolling; any other key stops it and does its usual job.
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape));
|
||||
self.cancel();
|
||||
self.block_input = true;
|
||||
self.cancel_button = cancel_button;
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
if let Some(pressed_at) = middle_press {
|
||||
if self.active() {
|
||||
self.cancel();
|
||||
self.block_input = true;
|
||||
self.cancel_button = Some(PointerButton::Middle);
|
||||
return Vec2::ZERO;
|
||||
} else if !cancel && viewport.intersect(ui.clip_rect()).contains(pressed_at) && ctx.layer_id_at(pressed_at) == Some(ui.layer_id()) {
|
||||
self.anchor = Some(pressed_at);
|
||||
self.holding = true;
|
||||
self.dragged = false;
|
||||
}
|
||||
}
|
||||
let (Some(anchor), Some(pointer)) = (self.anchor, pointer) else { return Vec2::ZERO };
|
||||
let displacement = pointer.y - anchor.y;
|
||||
if self.holding {
|
||||
self.dragged |= displacement.abs() > DEAD_ZONE;
|
||||
if !middle_down || middle_released {
|
||||
self.holding = false;
|
||||
if self.dragged {
|
||||
self.cancel();
|
||||
self.block_input = true;
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
if displacement.is_finite() && displacement.abs() > DEAD_ZONE {
|
||||
// Continuous redraws let stable_dt use measured frame time, so the speed doesn't
|
||||
// depend on the frame rate.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
vec2(0.0, scroll_delta(displacement, dt))
|
||||
}
|
||||
|
||||
/// Draw a marker at the anchor, above the pages, and the up-down cursor. Run after the
|
||||
/// canvas has set its own cursor.
|
||||
pub(crate) fn paint(&self, ui: &egui::Ui, viewport: egui::Rect) {
|
||||
let Some(anchor) = self.anchor else { return };
|
||||
let painter = ui.painter().with_clip_rect(viewport);
|
||||
let ink = ui.visuals().text_color();
|
||||
painter.circle(anchor, 13.0, ui.visuals().window_fill(), Stroke::new(1.0, ink));
|
||||
painter.circle_filled(anchor, 2.0, ink);
|
||||
for direction in [-1.0, 1.0] {
|
||||
painter.add(egui::Shape::convex_polygon(
|
||||
vec![anchor + vec2(-4.0, direction * 6.0), anchor + vec2(4.0, direction * 6.0), anchor + vec2(0.0, direction * 10.0)],
|
||||
ink,
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_delta(displacement: f32, dt: f32) -> f32 {
|
||||
if !displacement.is_finite() || !dt.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let distance = displacement.abs();
|
||||
if distance <= DEAD_ZONE {
|
||||
return 0.0;
|
||||
}
|
||||
let speed = distance.min(MAX_DISPLACEMENT).powf(SPEED_EXPONENT) * SPEED_MULTIPLIER;
|
||||
// Pointer below the anchor scrolls down: the content moves up. A stalled frame (an idle or
|
||||
// hidden window) mustn't jump pages.
|
||||
-displacement.signum() * speed * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn speed_has_a_dead_zone_grows_with_distance_and_rejects_invalid_input() {
|
||||
for y in [-15.0, -1.0, 0.0, 1.0, 15.0] {
|
||||
assert_eq!(scroll_delta(y, 0.016), 0.0);
|
||||
}
|
||||
assert!(scroll_delta(30.0, 0.016) < 0.0, "below the anchor scrolls down");
|
||||
assert_eq!(scroll_delta(30.0, 0.016), -scroll_delta(-30.0, 0.016));
|
||||
for (near, far) in [(16.0, 30.0), (30.0, 100.0), (100.0, 200.0)] {
|
||||
assert!(scroll_delta(far, 0.016).abs() > scroll_delta(near, 0.016).abs());
|
||||
}
|
||||
assert_eq!(scroll_delta(1000.0, 10.0), scroll_delta(1000.0, 0.05));
|
||||
assert_eq!(scroll_delta(1000.0, -1.0), 0.0);
|
||||
assert!(scroll_delta(f32::MAX, 0.016).is_finite());
|
||||
for invalid in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(scroll_delta(invalid, 0.016), 0.0);
|
||||
assert_eq!(scroll_delta(1000.0, invalid), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Options, About.
|
||||
//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Close, Options, About.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
@@ -6,7 +6,7 @@ use serde_json::json;
|
||||
use crate::theme::{APP_COLOR, Tokens, medium, regular, semibold};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
const PAGES: [(&str, &str); 9] = [
|
||||
const PAGES: [(&str, &str); 10] = [
|
||||
("home", "Home"),
|
||||
("new", "New"),
|
||||
("open", "Open"),
|
||||
@@ -15,11 +15,17 @@ const PAGES: [(&str, &str); 9] = [
|
||||
("saveAs", "Save As"),
|
||||
("print", "Print"),
|
||||
("export", "Export"),
|
||||
("close", "Close"),
|
||||
("options", "Options"),
|
||||
];
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// No document open (#1129): only the pages that open or create one, and Options.
|
||||
let has_document = app.has_document();
|
||||
if !has_document && !works_without_document(&app.ui.backstage_page) {
|
||||
app.ui.backstage_page = "home".into();
|
||||
}
|
||||
let mac = app.integrated_titlebar;
|
||||
egui::Panel::left("backstage_nav")
|
||||
.exact_size(200.0)
|
||||
@@ -58,12 +64,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(if mac { 12.0 } else { 6.0 });
|
||||
for (id, label) in PAGES {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label)));
|
||||
// No document open (#1129): Info, Save, Save As, Print and Export are greyed out.
|
||||
let on = has_document || works_without_document(id);
|
||||
let (r, resp) = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click())).inner;
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!(label)));
|
||||
let active = app.ui.backstage_page == id;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
|
||||
} else if resp.hovered() {
|
||||
} else if on && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(
|
||||
@@ -71,7 +79,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
Align2::LEFT_CENTER,
|
||||
tl!(label),
|
||||
if active { semibold(14.0) } else { medium(14.0) },
|
||||
egui::Color32::WHITE,
|
||||
if on { egui::Color32::WHITE } else { egui::Color32::from_white_alpha(110) },
|
||||
);
|
||||
if resp.clicked() {
|
||||
match id {
|
||||
@@ -93,6 +101,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
"saveAs" => {
|
||||
app.ui.backstage_page = "saveAs".into();
|
||||
}
|
||||
// Not a page: `file.close` asks Save / Don't Save / Cancel first when there
|
||||
// are unsaved changes, and Cancel leaves the Backstage where it was.
|
||||
"close" => {
|
||||
let _ = app.run("file.close", json!({}));
|
||||
}
|
||||
_ => app.ui.backstage_page = id.into(),
|
||||
}
|
||||
}
|
||||
@@ -129,6 +142,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The Backstage pages that work while no document is open (#1129).
|
||||
fn works_without_document(page: &str) -> bool {
|
||||
matches!(page, "home" | "new" | "open" | "options")
|
||||
}
|
||||
|
||||
fn heading(ui: &mut Ui, s: &str) {
|
||||
ui.label(egui::RichText::new(tl!(s)).font(semibold(26.0)));
|
||||
ui.add_space(16.0);
|
||||
@@ -170,8 +188,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
if let Some(h) = tex {
|
||||
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!(label)).font(regular(12.5)));
|
||||
if resp.clicked() {
|
||||
// The caption opens the template too, as in Word.
|
||||
let caption = ui.add(egui::Label::new(egui::RichText::new(tl!(label)).font(regular(12.5))).sense(Sense::click()));
|
||||
if resp.clicked() || caption.clicked() {
|
||||
let _ = app.run("file.new", json!({"template": template}));
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
@@ -587,6 +606,60 @@ mod tests {
|
||||
assert!(!h.state().ui.backstage, "successful save closes backstage");
|
||||
}
|
||||
|
||||
/// #1124: the side menu had no Close. It sits after Export and closes the document, asking
|
||||
/// first when there are unsaved changes; Cancel leaves the Backstage as it was.
|
||||
#[test]
|
||||
fn backstage_close_asks_then_closes() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("text.insert", json!({"text": "Draft"})).unwrap();
|
||||
a.run("ui.backstage", json!({"value": true, "page": "info"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
let export = h.get_by_label("Export").rect().min.y;
|
||||
assert!(h.get_by_label("Close").rect().min.y > export, "Close comes after Export");
|
||||
h.get_by_label("Close").click();
|
||||
h.run_steps(4);
|
||||
assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "unsaved changes ask first");
|
||||
assert!(!h.state().quit_requested);
|
||||
|
||||
h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().quit_requested, "Cancel keeps the document open");
|
||||
assert!(h.state().ui.backstage && h.state().ui.backstage_page == "info", "Cancel leaves the Backstage as it was");
|
||||
|
||||
h.get_by_label("Close").click();
|
||||
h.run_steps(4);
|
||||
h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||||
assert!(h.state().quit_requested, "Don't Save closes");
|
||||
}
|
||||
|
||||
/// A document without unsaved changes closes without a prompt.
|
||||
#[test]
|
||||
fn backstage_close_on_a_clean_document_closes_at_once() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Close").click();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().dialog.is_none(), "no prompt");
|
||||
assert!(h.state().quit_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backstage_save_as_sidebar_opens_save_as_page() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
|
||||
+290
-39
@@ -53,6 +53,8 @@ pub struct CanvasState {
|
||||
/// An animated column-turn destination; intermediate offsets are allowed during the motion.
|
||||
read_target_page: Option<usize>,
|
||||
pub caret_visible_since: f64,
|
||||
/// The caret's colour and the fingerprint of where it was worked out ([`caret_color_at`]).
|
||||
caret_bg: Option<(u64, Color32)>,
|
||||
dragging: bool,
|
||||
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
|
||||
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
|
||||
@@ -98,6 +100,8 @@ pub struct CanvasState {
|
||||
pub(crate) ink_ui: crate::ink::InkUi,
|
||||
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
|
||||
pub(crate) wheel: crate::scroll::CanvasScroll,
|
||||
/// Middle-click scrolling (#1065).
|
||||
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
|
||||
/// The scroll offset and its maximum at the end of last frame.
|
||||
pub(crate) scroll_offset: egui::Vec2,
|
||||
scroll_max: egui::Vec2,
|
||||
@@ -138,11 +142,6 @@ pub struct CanvasState {
|
||||
}
|
||||
|
||||
impl CanvasState {
|
||||
/// The cached rendering of a page.
|
||||
pub(crate) fn page_texture(&self, page: usize) -> Option<&TextureHandle> {
|
||||
self.textures.get(&page).map(|(_, t)| t)
|
||||
}
|
||||
|
||||
/// How many pages have a cached rendering.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn texture_count(&self) -> usize {
|
||||
@@ -165,6 +164,7 @@ impl Default for CanvasState {
|
||||
read_first_visible: None,
|
||||
read_target_page: None,
|
||||
caret_visible_since: 0.0,
|
||||
caret_bg: None,
|
||||
dragging: false,
|
||||
column_drag: None,
|
||||
last_press: None,
|
||||
@@ -194,6 +194,7 @@ impl Default for CanvasState {
|
||||
pasted: false,
|
||||
swallow_text: false,
|
||||
wheel: Default::default(),
|
||||
auto_scroll: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
scroll_request: None,
|
||||
@@ -722,6 +723,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.read_first_visible = None;
|
||||
app.canvas.read_target_page = None;
|
||||
}
|
||||
// Keys first, so this frame lays out and paints what was typed (handled after painting, a
|
||||
// keystroke showed one frame later). Focus is the canvas's from the last frame.
|
||||
// While "Save changes?" is up, keys answer it rather than edit the document behind it.
|
||||
if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) {
|
||||
crate::keys::canvas_events(app, ui.ctx());
|
||||
}
|
||||
crate::ink::replay_tick(app, ui.ctx());
|
||||
let layout = app.session.layout();
|
||||
if let Some(anchor) = reflow_anchor
|
||||
@@ -731,9 +738,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.read_target_page = None;
|
||||
app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));
|
||||
}
|
||||
// View › Switch Modes: dark paper, inverted document colours, a white caret.
|
||||
// View › Switch Modes: dark paper, inverted document colours.
|
||||
let dark_page = app.session.view.dark_mode;
|
||||
let caret_color = if dark_page { Color32::WHITE } else { t.caret };
|
||||
let dark_paper = wordcraft_render::DARK_PAPER;
|
||||
let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode;
|
||||
let (hruler, vruler, area) = if show_ruler {
|
||||
@@ -866,7 +872,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
} else {
|
||||
egui::Vec2::ZERO
|
||||
};
|
||||
let delta = wheel_delta + auto_delta;
|
||||
// Middle-click scrolling (#1065): Print Layout and the other editing views, not Read Mode.
|
||||
let middle_delta = if app.session.view.read_mode || app.dialog.is_some() {
|
||||
app.canvas.auto_scroll.cancel();
|
||||
egui::Vec2::ZERO
|
||||
} else {
|
||||
app.canvas.auto_scroll.update(ui, area)
|
||||
};
|
||||
let delta = wheel_delta + auto_delta + middle_delta;
|
||||
let mut scroll_area = egui::ScrollArea::both()
|
||||
.id_salt("canvas_scroll")
|
||||
.auto_shrink([false, false])
|
||||
@@ -927,18 +940,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// Render before placing the page: a page re-rendered in this frame (a zoom step, an
|
||||
// edit) then sits on the pixel grid in this frame too, rather than a frame later.
|
||||
if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
let mut opts = screen_render_options();
|
||||
opts.display.marks = app.session.view.marks;
|
||||
opts.display.always = app.session.prefs.marks;
|
||||
opts.display.placeholders = true;
|
||||
opts.display.markup = app.session.view.show_markup;
|
||||
// Ink Replay draws the ink itself, stroke by stroke, over pages without it.
|
||||
opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay;
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = dark_page;
|
||||
opts.dark_paper = dark_paper;
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
let opts = page_render_options(app, dim_body);
|
||||
let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts);
|
||||
let px = img.to_straight();
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px);
|
||||
@@ -1130,7 +1132,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||||
let look = crate::caret_glide::look(app, ui, content_origin, &geo, [pos2(x, y0), pos2(x, y1)], focused, since, on);
|
||||
if look.opacity > 0.0 && focused {
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, t.caret.gamma_multiply(look.opacity)));
|
||||
let caret_color = caret_color_at(app, &layout, pi, ex + slot.x, base - slot.y + (slot.d - slot.a) / 2.0, view, dim_body);
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity)));
|
||||
}
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||||
@@ -1156,6 +1159,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let y1 = y0 + c.height * scale;
|
||||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||||
// The caret contrasts with what lies under it, like Word's inverted caret (#299):
|
||||
// worked out only when something is drawn in it.
|
||||
let caret_color = |app: &mut WordApp| caret_color_at(app, &layout, c.page, c.x + c.width / 2.0, c.top + c.height / 2.0, view, dim_body);
|
||||
let bar = if c.width > 0.0 {
|
||||
// Turned text (a table cell's text direction): the caret lies across the page.
|
||||
[pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)]
|
||||
@@ -1166,7 +1172,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let shown = app.session.sel.is_collapsed() && focused;
|
||||
let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on);
|
||||
if shown && look.opacity > 0.0 {
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity)));
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, caret_color(app).gamma_multiply(look.opacity)));
|
||||
}
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||||
@@ -1193,6 +1199,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
crate::theme::install_fonts_extra(ui.ctx(), app.fonts_hans, app.fonts_system_cjk, &app.canvas.ime_faces);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let caret_color = caret_color(app);
|
||||
let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color);
|
||||
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color));
|
||||
}
|
||||
@@ -1218,8 +1225,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
// the scroll area is built, so it reads last frame's viewport.
|
||||
app.canvas.viewport = Some(out.inner_rect);
|
||||
let (resp, rects) = out.inner;
|
||||
// A click on the page leaves the selected comment balloon.
|
||||
if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() {
|
||||
// A click on the page leaves the selected comment balloon; a click inside a balloon (its
|
||||
// Delete or Resolve button, say) does not (#1085).
|
||||
let in_balloon = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| app.canvas.balloon_rects.iter().any(|(_, r)| r.contains(p)));
|
||||
if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() && !in_balloon {
|
||||
deselect_balloon(app, ui.ctx());
|
||||
}
|
||||
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
|
||||
@@ -1235,6 +1244,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.focused = resp.has_focus();
|
||||
// Screen readers: the document under the canvas, with the caret (#488).
|
||||
crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area);
|
||||
// The buttons that start or stop middle-click scrolling don't also click the page.
|
||||
let middle_gesture = app.canvas.auto_scroll.blocks_input();
|
||||
// Double-click the space between two pages (or the line between them) to hide or show it.
|
||||
if resp.double_clicked()
|
||||
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
|
||||
@@ -1245,14 +1256,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space}));
|
||||
} else
|
||||
// Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing.
|
||||
if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
|
||||
if !middle_gesture && !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
|
||||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||||
}
|
||||
// A click on a check box ticks it; on a list's button opens the list.
|
||||
crate::content_controls::clicks(app, &resp);
|
||||
if !middle_gesture {
|
||||
crate::content_controls::clicks(app, &resp);
|
||||
}
|
||||
let secondary_clicked = resp.secondary_clicked() && !middle_gesture;
|
||||
// Selected table cells: a right-click off them drops them, even between the selection's ends
|
||||
// (beside a selected column), so the menu acts on the cell clicked.
|
||||
if resp.secondary_clicked()
|
||||
if secondary_clicked
|
||||
&& let Some(range) = app.session.cell_range()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p)
|
||||
@@ -1266,7 +1280,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
// Right-click: move the caret there (unless inside the selection), then the context menu.
|
||||
// Right-click in an equation puts the caret there and opens the equation menu.
|
||||
if resp.secondary_clicked()
|
||||
if secondary_clicked
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p)
|
||||
{
|
||||
@@ -1282,7 +1296,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
// Right-click on a picture, shape or text box selects it (its menu offers Wrap Text).
|
||||
if resp.secondary_clicked()
|
||||
if secondary_clicked
|
||||
&& app.session.math.is_none()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p)
|
||||
@@ -1303,10 +1317,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if from_keys {
|
||||
context_suggestions(app);
|
||||
app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h));
|
||||
} else if resp.secondary_clicked() {
|
||||
} else if secondary_clicked {
|
||||
app.canvas.context_menu_at = resp.interact_pointer_pos();
|
||||
}
|
||||
let open = if resp.secondary_clicked() || from_keys {
|
||||
let open = if secondary_clicked || from_keys {
|
||||
Some(egui::SetOpenCommand::Bool(true))
|
||||
} else if resp.clicked() {
|
||||
Some(egui::SetOpenCommand::Bool(false))
|
||||
@@ -1338,10 +1352,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale);
|
||||
crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale);
|
||||
crate::table_pen::cursor(app, ui, &resp);
|
||||
// While "Save changes?" is up, keys answer it rather than edit the document behind it.
|
||||
if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) {
|
||||
crate::keys::canvas_events(app, ui.ctx());
|
||||
}
|
||||
app.canvas.auto_scroll.paint(ui, area);
|
||||
let _ = origin;
|
||||
if let (Some(h), Some(v)) = (hruler, vruler) {
|
||||
rulers(app, ui, h, v, &rects, &layout, geo.scale);
|
||||
@@ -2483,10 +2494,8 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
item(ui, app, "Cut", "edit.cut", json!({}));
|
||||
if ui.add(egui::Button::new(tl!("Copy")).shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
|
||||
if let Ok(r) = app.run("edit.copy", json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
ui.ctx().copy_text(t.to_string());
|
||||
if let Ok(r) = app.run("edit.copy", json!({})) {
|
||||
crate::system_clipboard::put_result(app, ui.ctx(), &r);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
@@ -2622,6 +2631,55 @@ fn field_showing_code(app: &WordApp, pos: &wordcraft_doc::Pos) -> Option<wordcra
|
||||
})
|
||||
}
|
||||
|
||||
/// Render options for this view's page rasters: marks, markup, dark page, header/body dimming.
|
||||
fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::RenderOptions {
|
||||
let mut opts = screen_render_options();
|
||||
opts.display.marks = app.session.view.marks;
|
||||
opts.display.always = app.session.prefs.marks;
|
||||
opts.display.placeholders = true;
|
||||
opts.display.markup = app.session.view.show_markup;
|
||||
// Ink Replay draws the ink itself, stroke by stroke, over pages without it.
|
||||
opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay;
|
||||
opts.display.revisions = app.session.prefs.markup.clone();
|
||||
opts.dark = app.session.view.dark_mode;
|
||||
opts.dark_paper = wordcraft_render::DARK_PAPER;
|
||||
opts.display.dim_header = !dim_body;
|
||||
opts.display.dim_body = dim_body;
|
||||
opts
|
||||
}
|
||||
|
||||
/// The caret colour that shows against `bg`: black or white, whichever contrasts more (WCAG
|
||||
/// contrast ratio), as Word's caret inverts what lies under it (#299). A white caret on white
|
||||
/// paper, or a black one on a dark page colour or highlight, would vanish.
|
||||
pub(crate) fn caret_contrast(bg: wordcraft_doc::props::Rgb) -> Color32 {
|
||||
let lin = |v: u8| {
|
||||
let c = v as f32 / 255.0;
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
let l = 0.2126 * lin(bg.0) + 0.7152 * lin(bg.1) + 0.0722 * lin(bg.2);
|
||||
// Contrast with black (l + 0.05) / 0.05 against with white 1.05 / (l + 0.05).
|
||||
if (l + 0.05) * (l + 0.05) >= 0.05 * 1.05 { Color32::BLACK } else { Color32::WHITE }
|
||||
}
|
||||
|
||||
/// The caret colour at page point (`x`, `y`) of page `page`: [`caret_contrast`] of the paper,
|
||||
/// shading or highlight there. Cached until the layout, the view or the point changes, so the
|
||||
/// blink doesn't rebuild the page's draw list every frame.
|
||||
fn caret_color_at(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, page: usize, x: f32, y: f32, view: u64, dim_body: bool) -> Color32 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(std::sync::Arc::as_ptr(layout) as usize, page, x.to_bits(), y.to_bits(), view).hash(&mut h);
|
||||
let key = h.finish();
|
||||
if let Some((k, c)) = app.canvas.caret_bg
|
||||
&& k == key
|
||||
{
|
||||
return c;
|
||||
}
|
||||
let Some(p) = layout.pages.get(page) else { return Color32::BLACK };
|
||||
let bg = wordcraft_render::background_at(&app.session.doc, p, &page_render_options(app, dim_body), x, y);
|
||||
let c = caret_contrast(bg);
|
||||
app.canvas.caret_bg = Some((key, c));
|
||||
c
|
||||
}
|
||||
|
||||
/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system
|
||||
/// draws it, so a page looks as it does in other Mac apps (exports never are).
|
||||
pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions {
|
||||
@@ -2689,6 +2747,59 @@ mod tests {
|
||||
assert!(reading_size(vec2(1440.0, 750.0), PX_PER_PT, Some(612.0)).0 <= 612.0);
|
||||
}
|
||||
|
||||
/// #299: the caret is black on light backgrounds and white on dark ones, whatever the UI
|
||||
/// theme: on white paper, a dark page colour, a dark page (Switch Modes) and a highlight.
|
||||
#[test]
|
||||
fn caret_contrasts_with_what_lies_under_it() {
|
||||
use wordcraft_doc::props::Rgb;
|
||||
assert_eq!(caret_contrast(Rgb::WHITE), Color32::BLACK);
|
||||
assert_eq!(caret_contrast(Rgb::BLACK), Color32::WHITE);
|
||||
assert_eq!(caret_contrast(Rgb(0xFF, 0xFF, 0x00)), Color32::BLACK, "yellow highlight");
|
||||
assert_eq!(caret_contrast(Rgb(0x00, 0x00, 0x80)), Color32::WHITE, "dark blue");
|
||||
assert_eq!(caret_contrast(Rgb(0x33, 0x33, 0x33)), Color32::WHITE, "dark paper");
|
||||
assert_eq!(caret_contrast(Rgb(0xC0, 0xC0, 0xC0)), Color32::BLACK, "light grey");
|
||||
|
||||
let doc = wordcraft_doc::Document::from_text("Hello WordCraft");
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(doc), Default::default());
|
||||
let at = |a: &mut WordApp, off: usize| {
|
||||
let layout = a.session.layout();
|
||||
let c = layout.caret_on(&Pos::body(0, off), 0).unwrap();
|
||||
let view = view_key(a, false);
|
||||
caret_color_at(a, &layout, c.page, c.x, c.top + c.height / 2.0, view, false)
|
||||
};
|
||||
assert_eq!(at(&mut a, 2), Color32::BLACK, "white paper");
|
||||
a.run("design.pageColor", json!({"color": "#102040"})).unwrap();
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "dark page colour");
|
||||
a.run("design.pageColor", json!({})).unwrap();
|
||||
assert_eq!(at(&mut a, 2), Color32::BLACK, "page colour removed");
|
||||
a.session.view.dark_mode = true;
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "dark page");
|
||||
a.session.view.dark_mode = false;
|
||||
a.run("select.text", json!({"text": "Hello"})).unwrap();
|
||||
a.run("format.highlight", json!({"color": "black"})).unwrap();
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "inside a black highlight");
|
||||
assert_eq!(at(&mut a, 9), Color32::BLACK, "past it");
|
||||
assert_eq!(at(&mut a, 2), Color32::WHITE, "again, from the cache");
|
||||
}
|
||||
|
||||
/// #299: the caret's colour is worked out only when the caret is drawn: with text selected
|
||||
/// there is no caret, so the page's draw list isn't built for it.
|
||||
#[test]
|
||||
fn caret_colour_is_not_worked_out_while_the_caret_is_hidden() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default());
|
||||
a.run("select.text", json!({"text": "Hello"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert!(h.state().canvas.caret_bg.is_none(), "no caret, no colour lookup");
|
||||
}
|
||||
|
||||
/// #167: dragging a ruler's margin edge sets that margin, snapped, never negative, and
|
||||
/// always leaving an inch of text.
|
||||
#[test]
|
||||
@@ -2924,6 +3035,79 @@ mod tests {
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
|
||||
}
|
||||
|
||||
/// #1085: a selected balloon's buttons work (pressing them is not a click on the page that
|
||||
/// leaves the balloon): a new comment with no text resolves and deletes from its balloon.
|
||||
#[test]
|
||||
fn an_empty_comment_balloon_deletes() {
|
||||
for body in ["", "Some text here"] {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
if !body.is_empty() {
|
||||
a.run("text.insert", json!({"text": body})).unwrap();
|
||||
a.run("select.text", json!({"text": "text"})).unwrap();
|
||||
}
|
||||
let id = a.run("review.newComment", json!({})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: egui::Pos2| {
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
let card = h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r).expect("the comment has a balloon");
|
||||
click(&mut h, card.left_top() + vec2(30.0, 8.0));
|
||||
assert_eq!(h.state().canvas.balloon, Some(id), "{body:?}: the click selected the balloon");
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let resolve = h.get_by_label("Resolve").rect().center();
|
||||
click(&mut h, resolve);
|
||||
assert!(h.state().session.doc.comments[&id].resolved, "{body:?}: Resolve worked");
|
||||
assert_eq!(h.state().canvas.balloon, Some(id), "{body:?}: the balloon stays selected");
|
||||
let del = h.get_by_label("Delete").rect().center();
|
||||
click(&mut h, del);
|
||||
assert!(h.state().session.doc.comments.is_empty(), "{body:?}: the empty comment was deleted");
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed character is laid out and painted in the frame that delivers the key, not the next
|
||||
/// one: keys are handled before the canvas takes the layout it paints.
|
||||
#[test]
|
||||
fn a_keystroke_is_painted_in_its_own_frame() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Some text"})).unwrap();
|
||||
a.canvas.want_focus = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().canvas.focused, "the canvas has the keyboard focus");
|
||||
h.event(egui::Event::Text("x".into()));
|
||||
h.step();
|
||||
assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "Some textx");
|
||||
let latest = h.state_mut().session.layout();
|
||||
let painted = h.state().canvas.page_keys.layout.clone();
|
||||
assert!(painted.is_some_and(|l| std::sync::Arc::ptr_eq(&l, &latest)), "the frame painted the layout with the typed character");
|
||||
}
|
||||
|
||||
/// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the
|
||||
/// comment has the new text as one undo step.
|
||||
#[test]
|
||||
@@ -3084,6 +3268,73 @@ mod tests {
|
||||
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll");
|
||||
}
|
||||
|
||||
/// #1065: a middle click starts scrolling, the pointer below the click scrolls down on its
|
||||
/// own frame after frame, and a second middle click stops it; a primary click stops it without
|
||||
/// moving the caret. Held down and dragged, letting go stops it.
|
||||
#[test]
|
||||
fn middle_click_scrolls_toward_the_pointer_until_clicked_again() {
|
||||
let text = vec!["A line of text in a long document."; 400].join("\n");
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&text)), Default::default());
|
||||
a.canvas.auto_scroll.supported = true;
|
||||
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(6);
|
||||
let anchor = h.state().canvas.viewport.expect("the canvas is shown").center();
|
||||
let button = |h: &mut H, at: Pos2, button, pressed| {
|
||||
h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
};
|
||||
let offset = |h: &H| h.state().canvas.scroll_offset.y;
|
||||
let below = anchor + vec2(0.0, 150.0);
|
||||
|
||||
h.event(egui::Event::PointerMoved(anchor));
|
||||
button(&mut h, anchor, egui::PointerButton::Middle, true);
|
||||
button(&mut h, anchor, egui::PointerButton::Middle, false);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().canvas.auto_scroll.active());
|
||||
assert_eq!(offset(&h), 0.0, "the pointer at the click doesn't scroll");
|
||||
h.event(egui::Event::PointerMoved(below));
|
||||
h.run_steps(10);
|
||||
let scrolled = offset(&h);
|
||||
assert!(scrolled > 50.0, "the pointer below the click scrolls down: {scrolled}");
|
||||
button(&mut h, below, egui::PointerButton::Middle, true);
|
||||
button(&mut h, below, egui::PointerButton::Middle, false);
|
||||
let stopped = offset(&h);
|
||||
h.run_steps(10);
|
||||
assert!(!h.state().canvas.auto_scroll.active());
|
||||
assert_eq!(offset(&h), stopped, "a second click stops it");
|
||||
|
||||
// A primary click stops it too, and isn't a click on the page.
|
||||
let caret = h.state().session.sel.clone();
|
||||
button(&mut h, anchor, egui::PointerButton::Middle, true);
|
||||
button(&mut h, anchor, egui::PointerButton::Middle, false);
|
||||
assert!(h.state().canvas.auto_scroll.active());
|
||||
button(&mut h, anchor, egui::PointerButton::Primary, true);
|
||||
button(&mut h, anchor, egui::PointerButton::Primary, false);
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().canvas.auto_scroll.active());
|
||||
assert_eq!(h.state().session.sel, caret, "the stopping click leaves the caret");
|
||||
|
||||
// Held down and dragged: scrolls while held, stops on release.
|
||||
h.event(egui::Event::PointerMoved(anchor));
|
||||
button(&mut h, anchor, egui::PointerButton::Middle, true);
|
||||
h.event(egui::Event::PointerMoved(below));
|
||||
h.run_steps(5);
|
||||
let held = offset(&h);
|
||||
assert!(held > stopped, "scrolls while the wheel is held");
|
||||
button(&mut h, below, egui::PointerButton::Middle, false);
|
||||
let released = offset(&h);
|
||||
h.run_steps(10);
|
||||
assert!(!h.state().canvas.auto_scroll.active());
|
||||
assert_eq!(offset(&h), released, "letting go after a drag stops it");
|
||||
}
|
||||
|
||||
/// A right-click at `at`, the button pressed and released a frame apart.
|
||||
fn right_click(h: &mut H, at: Pos2) {
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool {
|
||||
match id {
|
||||
"edit.undo" => app.session.can_undo(),
|
||||
"edit.redo" => app.session.can_redo(),
|
||||
"file.save" => true,
|
||||
"file.save" => app.has_document(),
|
||||
_ => crate::widgets::enabled(app, id),
|
||||
}
|
||||
}
|
||||
@@ -315,17 +315,22 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
// Centred title.
|
||||
let title = format!(
|
||||
"{} — {}",
|
||||
app.title_stem(),
|
||||
if app.session.dirty {
|
||||
tl!("Edited")
|
||||
} else if app.session.path.is_some() {
|
||||
tl!("Saved")
|
||||
} else {
|
||||
tl!("Not saved")
|
||||
}
|
||||
);
|
||||
let title = if !app.has_document() {
|
||||
// No document open (#1129): the app's name alone.
|
||||
"WordCraft".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} — {}",
|
||||
app.title_stem(),
|
||||
if app.session.dirty {
|
||||
tl!("Edited")
|
||||
} else if app.session.path.is_some() {
|
||||
tl!("Saved")
|
||||
} else {
|
||||
tl!("Not saved")
|
||||
}
|
||||
)
|
||||
};
|
||||
let font = TypeRung::Chrome.semibold();
|
||||
let track = TypeRung::Chrome.tracking();
|
||||
let w = text_width(ui.painter(), &title, &font, track, t.text);
|
||||
@@ -355,6 +360,10 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
.stroke(Stroke::new(1.0, t.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// No document open (#1129): an empty status bar.
|
||||
if !app.has_document() {
|
||||
return;
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
@@ -365,6 +374,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
|
||||
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s));
|
||||
resp
|
||||
};
|
||||
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
|
||||
@@ -446,10 +456,10 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, on) in [
|
||||
("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", read),
|
||||
for (icon, id, name, on) in [
|
||||
("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", tl!("Read Mode"), read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
@@ -458,7 +468,8 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
if resp.on_hover_text(id.trim_start_matches("view.")).clicked() {
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name));
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//!
|
||||
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
|
||||
//! - `engine.execute {command, params}`: run any command (engine or `ui.*`)
|
||||
//! - `engine.commands`: every command with its enablement
|
||||
//! - `engine.commands {search?}`: engine and app print commands with enablement and parameters
|
||||
//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf
|
||||
//! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point.
|
||||
//! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`:
|
||||
//! real pointer input in screen points (reaches every widget)
|
||||
//! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left)
|
||||
@@ -104,6 +105,9 @@ fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a request that needs a document while none is open (#1129).
|
||||
const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first";
|
||||
|
||||
pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
@@ -119,13 +123,22 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
|
||||
}
|
||||
"engine.commands" => {
|
||||
let reg = app.session.registry.clone();
|
||||
ok(Value::Array(
|
||||
reg.all()
|
||||
.iter()
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
|
||||
.collect(),
|
||||
))
|
||||
let mut commands: Vec<Value> = reg.all().iter()
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
|
||||
.collect();
|
||||
commands.extend(crate::printing::commands(app));
|
||||
let search = s("search").unwrap_or("").to_ascii_lowercase();
|
||||
if !search.is_empty() {
|
||||
commands.retain(|command| {
|
||||
["id", "label", "location", "params"]
|
||||
.iter()
|
||||
.any(|field| command.get(field).and_then(Value::as_str).is_some_and(|text| text.to_ascii_lowercase().contains(&search)))
|
||||
});
|
||||
}
|
||||
ok(Value::Array(commands))
|
||||
}
|
||||
// The Start screen's empty window has no document to inspect or render (#1129).
|
||||
"document.inspect" | "ui.render" if !app.has_document() => err(NO_DOCUMENT),
|
||||
"document.inspect" => match app.session.run("document.inspect", p) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
@@ -197,6 +210,25 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.wheel" => {
|
||||
// Mouse-wheel or trackpad scroll at (x, y): `dy` points (positive scrolls up), or
|
||||
// lines with `"unit": "line"`; `phase` "start"/"move"/"end" for a trackpad gesture.
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
let unit = match s("unit") {
|
||||
Some("line") => egui::MouseWheelUnit::Line,
|
||||
Some("page") => egui::MouseWheelUnit::Page,
|
||||
_ => egui::MouseWheelUnit::Point,
|
||||
};
|
||||
let phase = match s("phase") {
|
||||
Some("start") => egui::TouchPhase::Start,
|
||||
Some("end") => egui::TouchPhase::End,
|
||||
_ => egui::TouchPhase::Move,
|
||||
};
|
||||
let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0));
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p) });
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.text" => {
|
||||
let Some(t) = s("text") else { return err("missing text") };
|
||||
app.synthetic.push(egui::Event::Text(t.to_string()));
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, LABEL_PITCH, group, small};
|
||||
|
||||
/// What each structure button shows.
|
||||
fn button_picture(id: &str) -> &'static str {
|
||||
pub(crate) fn button_picture(id: &str) -> &'static str {
|
||||
match id {
|
||||
"fraction" => "x/y",
|
||||
"script" => "e^x",
|
||||
@@ -35,8 +35,8 @@ fn button_picture(id: &str) -> &'static str {
|
||||
fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, nodes: Option<wordcraft_doc::math::Arg>, display: bool, pt: f32) {
|
||||
let nodes = nodes.unwrap_or_else(|| parse_linear(linear));
|
||||
match crate::previews::math_texture(app, ui.ctx(), key, &nodes, display, pt, r.size()) {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Tokens::get(ui.ctx()).text);
|
||||
Some((tex, uv)) => {
|
||||
ui.painter().image(tex, r, uv, Tokens::get(ui.ctx()).text);
|
||||
}
|
||||
None => {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -31,7 +31,7 @@ pub enum FileDialogRequest {
|
||||
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
|
||||
pub type FileDialogAnswer = Result<Option<String>, String>;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
|
||||
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
|
||||
@@ -215,7 +215,13 @@ impl WordApp {
|
||||
}
|
||||
AfterPick::Export { ext } => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) };
|
||||
let r = if ext == "png" {
|
||||
self.run("file.exportPng", json!({"path": path}))
|
||||
} else if ext == crate::print_page::EXPORT_WORD_2003 {
|
||||
self.run("file.saveAs", json!({"path": path, "format": ext}))
|
||||
} else {
|
||||
self.run("file.saveAs", json!({"path": path}))
|
||||
};
|
||||
if r.is_ok() {
|
||||
self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)]));
|
||||
}
|
||||
@@ -272,7 +278,8 @@ impl WordApp {
|
||||
|
||||
/// File › Export: ask where to save a copy in this format, then write it.
|
||||
pub(crate) fn export_dialog(&mut self, ext: &str) {
|
||||
let name = format!("{}.{ext}", self.title_stem());
|
||||
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
|
||||
let name = format!("{}.{file_ext}", self.title_stem());
|
||||
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
Legal style numbering Номериране в юридически стил
|
||||
Link level to style: Свързване на нивото със стил:
|
||||
List Definition Дефиниция на списък
|
||||
No document is open. Create a document or open one first. Няма отворен документ. Първо създайте или отворете документ.
|
||||
Nothing Нищо
|
||||
Number alignment: Подравняване на номера:
|
||||
Number format Формат на номера
|
||||
@@ -1359,7 +1360,8 @@
|
||||
Edit Field… Редактиране на поле…
|
||||
Formatted Форматиран
|
||||
Interface size: Размер на интерфейса:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Уголемява или смалява лентата, менютата и диалоговите прозорци в допълнение към мащабирането на дисплея в системата. Мащабът на документа е отделна настройка.
|
||||
Auto ({percent}%) Автоматично ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Уголемява или смалява лентата, менютата и диалоговите прозорци. „Автоматично“ следва мащабирането на дисплея в системата; 100% е един пиксел от екрана на точка. Мащабът на документа е отделна настройка.
|
||||
In Line with Text На един ред с текста
|
||||
Square Квадрат
|
||||
Through През
|
||||
@@ -2158,6 +2160,7 @@
|
||||
Use the Insert key to switch overtype on and off Използване на клавиша Insert за включване и изключване на заместването
|
||||
Extend Selection by Units Разширяване на селекцията по единици
|
||||
Word XML Document (*.xml) XML документ на Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) XML документ на Word 2003 (*.xml)
|
||||
Deleted Изтрито
|
||||
Print Markup Печат на маркировките
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Коментари до всяка страница и проследени промени в PDF файлове, разпечатки и изображения; списък с коментари в края при уеб страници, Markdown и текст.
|
||||
@@ -2464,3 +2467,10 @@
|
||||
Table Properties… Свойства на таблица…
|
||||
Search Търсене
|
||||
Title Bar Заглавна лента
|
||||
Privacy Options Настройки за поверителност
|
||||
Trust Center Център за сигурност
|
||||
Privacy for this document Поверителност на този документ
|
||||
Remove my name from comments, changes and file properties when saving Премахване на името ми от коментарите, промените и свойствата на файла при запазване
|
||||
Leave out the date and time of comments and tracked changes when saving Без дата и час на коментарите и проследените промени при запазване
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Документът, който редактирате, запазва имената и датите; само записаният файл ги пропуска.
|
||||
Privacy Settings Текущи настройки за поверителност
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -301,6 +301,7 @@
|
||||
Deletions: Odstranění:
|
||||
Description: Popis:
|
||||
Design Návrh
|
||||
No document is open. Create a document or open one first. Není otevřen žádný dokument. Nejprve dokument vytvořte nebo otevřete.
|
||||
development build vývojové sestavení
|
||||
Diagonal Diagonální
|
||||
Diamond Kosočtverec
|
||||
@@ -1353,7 +1354,8 @@
|
||||
Edit Field… Upravit pole…
|
||||
Formatted Formátováno
|
||||
Interface size: Velikost rozhraní:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Zvětší nebo zmenší pás karet, nabídky a dialogová okna nad rámec škálování displeje v systému. Lupa dokumentu se nastavuje zvlášť.
|
||||
Auto ({percent}%) Automaticky ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Zvětší nebo zmenší pás karet, nabídky a dialogová okna. Automaticky sleduje škálování displeje v systému; 100% je jeden pixel obrazovky na bod. Lupa dokumentu se nastavuje zvlášť.
|
||||
In Line with Text V řádku s textem
|
||||
Square Čtverec
|
||||
Through Průchozí
|
||||
@@ -1876,6 +1878,7 @@
|
||||
Animate the caret (it glides and fades) Animovat kurzor (plynule se přesouvá a prolíná)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Zapnuto: kurzor se při psaní nebo použití kláves se šipkami plynule přesune na nové místo a jeho blikání se prolíná. Vypnuto: kurzor přeskakuje a blikání se ostře zapíná a vypíná.
|
||||
Word XML Document (*.xml) Dokument Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Dokument Word 2003 XML (*.xml)
|
||||
Deleted Odstraněno
|
||||
Resize Table by Dragging Změnit velikost tabulky přetažením
|
||||
Select Column Vybrat sloupec
|
||||
@@ -2080,3 +2083,10 @@
|
||||
Automatic ({region}) Automaticky ({region})
|
||||
Search Hledat
|
||||
Title Bar Záhlaví okna
|
||||
Privacy Options Možnosti ochrany osobních údajů
|
||||
Trust Center Centrum zabezpečení
|
||||
Privacy for this document Soukromí tohoto dokumentu
|
||||
Remove my name from comments, changes and file properties when saving Při ukládání odebrat mé jméno z komentářů, změn a vlastností souboru
|
||||
Leave out the date and time of comments and tracked changes when saving Při ukládání vynechat datum a čas komentářů a sledovaných změn
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Upravovaný dokument si jména a data ponechá; vynechá je jen uložený soubor.
|
||||
Privacy Settings Aktuální nastavení soukromí
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -104,6 +104,7 @@
|
||||
Link level to style: Ebene mit Formatvorlage verknüpfen:
|
||||
List Definition Listendefinition
|
||||
More templates → Weitere Vorlagen →
|
||||
No document is open. Create a document or open one first. Es ist kein Dokument geöffnet. Erstellen oder öffnen Sie zuerst ein Dokument.
|
||||
No templates match your search. Keine Vorlagen entsprechen Ihrer Suche.
|
||||
Nothing Keine
|
||||
Number alignment: Nummernausrichtung:
|
||||
@@ -1324,7 +1325,8 @@
|
||||
Flip Horizontal Horizontal spiegeln
|
||||
Formatted Formatiert
|
||||
Interface size: Oberflächengröße:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Vergrößert oder verkleinert Menüband, Menüs und Dialoge, zusätzlich zur Anzeigeskalierung Ihres Systems. Die Dokumentansicht ist davon getrennt.
|
||||
Auto ({percent}%) Automatisch ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Vergrößert oder verkleinert Menüband, Menüs und Dialoge. „Automatisch“ folgt der Anzeigeskalierung Ihres Systems; 100% ist ein Bildschirmpixel pro Punkt. Die Dokumentansicht ist davon getrennt.
|
||||
Base text: Basistext:
|
||||
Distribute letters Buchstaben verteilen
|
||||
Distribute spacing Abstände verteilen
|
||||
@@ -1459,6 +1461,7 @@
|
||||
Animate the caret (it glides and fades) Einfügemarke animieren (sie gleitet und blendet aus)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ein: Die Einfügemarke gleitet beim Tippen oder mit den Pfeiltasten an ihre neue Stelle, und ihr Blinken blendet weich. Aus: Sie springt und blinkt hart an und aus.
|
||||
Word XML Document (*.xml) Word-XML-Dokument (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word-2003-XML-Dokument (*.xml)
|
||||
Choose the sheet that holds the recipients. Wählen Sie das Tabellenblatt mit den Empfängern.
|
||||
First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften
|
||||
Deleted Gelöscht
|
||||
@@ -2074,3 +2077,10 @@
|
||||
Automatic ({region}) Automatisch ({region})
|
||||
Search Suchen
|
||||
Title Bar Titelleiste
|
||||
Privacy Options Datenschutzoptionen
|
||||
Trust Center Sicherheitscenter
|
||||
Privacy for this document Datenschutz für dieses Dokument
|
||||
Remove my name from comments, changes and file properties when saving Beim Speichern meinen Namen aus Kommentaren, Änderungen und Dateieigenschaften entfernen
|
||||
Leave out the date and time of comments and tracked changes when saving Beim Speichern Datum und Uhrzeit von Kommentaren und nachverfolgten Änderungen weglassen
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Das bearbeitete Dokument behält Namen und Daten; nur die gespeicherte Datei lässt sie weg.
|
||||
Privacy Settings Aktuelle Datenschutzeinstellungen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -116,6 +116,7 @@
|
||||
Millimeters Milímetros
|
||||
New comments and tracked changes are recorded under this name and these initials. Los comentarios y cambios con seguimiento nuevos se registran con este nombre y estas iniciales.
|
||||
New word Palabra nueva
|
||||
No document is open. Create a document or open one first. No hay ningún documento abierto. Crea uno o abre uno primero.
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Aún no hay palabras. Añadir al diccionario sobre una palabra mal escrita la pone aquí.
|
||||
Mirror indents Sangrías simétricas
|
||||
Nothing Nada
|
||||
@@ -999,6 +1000,7 @@
|
||||
Word Right Palabra a la derecha
|
||||
Word document (*.docx) Documento de Word (*.docx)
|
||||
Word XML Document (*.xml) Documento XML de Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Documento XML de Word 2003 (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
WordCraft is now the default app for Word documents. WordCraft es ahora la aplicación predeterminada para los documentos de Word.
|
||||
@@ -1459,7 +1461,8 @@
|
||||
Formatted Con formato
|
||||
Deleted Eliminado
|
||||
Interface size: Tamaño de la interfaz:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte.
|
||||
Auto ({percent}%) Auto ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo. Auto sigue el escalado de pantalla del sistema; 100% es un píxel de pantalla por punto. El zoom del documento es aparte.
|
||||
Resize Table by Dragging Cambiar tamaño de tabla arrastrando
|
||||
Print Markup Imprimir marcas
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Comentarios junto a cada página y cambios marcados en PDF, impresiones e imágenes; una lista de comentarios al final de páginas web, Markdown y texto.
|
||||
@@ -2441,3 +2444,10 @@ preview Title Título
|
||||
Start-up Inicio de la aplicación
|
||||
Show the Start screen when WordCraft starts Mostrar la pantalla de inicio al abrir WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activado: WordCraft se abre en Archivo › Inicio, con un documento en blanco, las plantillas y tus documentos recientes. Desactivado: abre un documento en blanco.
|
||||
Privacy Options Opciones de privacidad
|
||||
Trust Center Centro de confianza
|
||||
Privacy for this document Privacidad de este documento
|
||||
Remove my name from comments, changes and file properties when saving Quitar mi nombre de los comentarios, los cambios y las propiedades del archivo al guardar
|
||||
Leave out the date and time of comments and tracked changes when saving Omitir la fecha y la hora de los comentarios y los cambios controlados al guardar
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. El documento que editas conserva los nombres y las fechas; solo el archivo guardado los omite.
|
||||
Privacy Settings Configuración de privacidad actual
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -482,6 +482,7 @@
|
||||
No Paragraph Space Lõiguvaheta
|
||||
No Spacing Vahedeta
|
||||
No contributor data was built into this copy. Selles versioonis pole kaasautorite andmeid.
|
||||
No document is open. Create a document or open one first. Ükski dokument pole avatud. Looge või avage kõigepealt dokument.
|
||||
No model credits were built into this copy. Selles versioonis pole mudelite autoriteavet.
|
||||
No templates match your search. Otsingule vastavaid malle pole.
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Sõnu veel pole. Valik „Lisa sõnastikku“ valesti kirjutatud sõnal lisab selle siia.
|
||||
@@ -877,6 +878,7 @@
|
||||
Word Right Sõna paremale
|
||||
Word document (*.docx) Wordi dokument (*.docx)
|
||||
Word XML Document (*.xml) Wordi XML-dokument (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML-dokument (*.xml)
|
||||
WordArt WordArt
|
||||
WordCraft WordCraft
|
||||
Words Sõnad
|
||||
@@ -1438,7 +1440,8 @@
|
||||
Formatted Vormindatud
|
||||
Deleted Kustutatud
|
||||
Interface size: Kasutajaliidese suurus:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
|
||||
Auto ({percent}%) Automaatne ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks. Automaatne järgib süsteemi ekraani skaleerimist; 100% on üks ekraanipiksel punkti kohta. Dokumendi suum on eraldi.
|
||||
Resize Table by Dragging Tabeli suuruse muutmine lohistades
|
||||
Print Markup Prindi märgistus
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentaarid iga lehe kõrval ja jälitatud muudatused PDF-ides, väljatrükkides ja piltides; kommentaaride loend veebilehtede, Markdowni ja teksti lõpus.
|
||||
@@ -2109,3 +2112,10 @@
|
||||
Start-up Käivitamine
|
||||
Show the Start screen when WordCraft starts Näita WordCrafti käivitamisel avakuva
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Sees: WordCraft avaneb vaates Fail › Avaleht, kus on tühi dokument, mallid ja hiljutised dokumendid. Väljas: avaneb tühi dokument.
|
||||
Privacy Options Privaatsussuvandid
|
||||
Trust Center Usalduskeskus
|
||||
Privacy for this document Selle dokumendi privaatsus
|
||||
Remove my name from comments, changes and file properties when saving Eemalda salvestamisel minu nimi kommentaaridest, muudatustest ja faili atribuutidest
|
||||
Leave out the date and time of comments and tracked changes when saving Jäta salvestamisel välja kommentaaride ja jälitatud muudatuste kuupäev ja kellaaeg
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Redigeeritav dokument säilitab nimed ja kuupäevad; need jäetakse välja ainult salvestatud failist.
|
||||
Privacy Settings Praegused privaatsussätted
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 459 and column 13.
|
@@ -96,12 +96,13 @@
|
||||
Link level to style: Lier le niveau au style :
|
||||
List Definition Définition de liste
|
||||
Mirror indents Retraits en miroir
|
||||
No document is open. Create a document or open one first. Aucun document n’est ouvert. Créez ou ouvrez d’abord un document.
|
||||
Nothing Rien
|
||||
Number alignment: Alignement des numéros :
|
||||
Number format Format des numéros
|
||||
Number style for this level: Style de numéros pour ce niveau :
|
||||
Outside border Bordure extérieure
|
||||
Overtype Remplacement
|
||||
Overtype Refrappe
|
||||
Outside: Extérieur :
|
||||
Restart list after: Redémarrer la liste après :
|
||||
Right border Bordure droite
|
||||
@@ -1354,7 +1355,8 @@
|
||||
Edit Field… Modifier le champ…
|
||||
Formatted Mis en forme
|
||||
Interface size: Taille de l’interface :
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agrandit ou réduit le ruban, les menus et les boîtes de dialogue en plus de la mise à l’échelle d’affichage de votre système. Le zoom du document est distinct.
|
||||
Auto ({percent}%) Auto ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Agrandit ou réduit le ruban, les menus et les boîtes de dialogue. Auto suit la mise à l’échelle d’affichage de votre système ; 100% correspond à un pixel d’écran par point. Le zoom du document est distinct.
|
||||
1. Formatting restrictions 1. Restrictions de mise en forme
|
||||
2. Editing restrictions 2. Restrictions de modification
|
||||
3. Start enforcement 3. Démarrer la protection
|
||||
@@ -1627,8 +1629,8 @@
|
||||
SmartArt Design Création SmartArt
|
||||
SmartArt Styles Styles SmartArt
|
||||
Text Pane Volet Texte
|
||||
Promote to Heading 1 Promouvoir en Titre 1
|
||||
Demote to Body Text Abaisser en corps de texte
|
||||
Promote to Heading 1 Élever au niveau Titre 1
|
||||
Demote to Body Text Abaisser au niveau Corps de texte
|
||||
Move Up Monter
|
||||
Move Down Descendre
|
||||
Collapse Réduire
|
||||
@@ -1649,7 +1651,7 @@
|
||||
Show Text Formatting Afficher la mise en forme du texte
|
||||
Close Outline View Fermer le mode Plan
|
||||
Outline State État du plan
|
||||
Outlining Plan
|
||||
Outlining Mode Plan
|
||||
Outline Tools Outils Plan
|
||||
A, B, C, … A, B, C, …
|
||||
Above the item Au-dessus de l’élément
|
||||
@@ -1862,7 +1864,7 @@
|
||||
# Interface strings added on main since this catalog was first written.
|
||||
As you type Pendant la frappe
|
||||
Asian text font: Police du texte asiatique :
|
||||
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous arrêtez de taper.
|
||||
AutoSave writes your changes to the file a moment after you stop typing. L’enregistrement automatique écrit vos modifications dans le fichier peu après que vous avez cessé de taper.
|
||||
Centimeters Centimètres
|
||||
Check spelling as you type Vérifier l’orthographe pendant la frappe
|
||||
Click to switch back to inserting Cliquez pour revenir au mode insertion
|
||||
@@ -1882,8 +1884,8 @@
|
||||
Millimeters Millimètres
|
||||
New comments and tracked changes are recorded under this name and these initials. Les nouveaux commentaires et les modifications suivies sont enregistrés sous ce nom et ces initiales.
|
||||
New word Nouveau mot
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Les mots ajoutés avec Ajouter au dictionnaire sur un mot mal orthographié apparaissent ici.
|
||||
Overtype (typing replaces the characters after the caret) Remplacement (la frappe remplace les caractères situés après le curseur)
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. Aucun mot pour l’instant. Utilisez Ajouter au dictionnaire sur un mot mal orthographié pour l’ajouter ici.
|
||||
Overtype (typing replaces the characters after the caret) Refrappe (la frappe remplace les caractères situés après le curseur)
|
||||
Page display Affichage de la page
|
||||
Paragraph marks Marques de paragraphe
|
||||
Personalize Personnaliser
|
||||
@@ -1902,12 +1904,13 @@
|
||||
These marks show even when Show/Hide ¶ is off. Ces marques s’affichent même lorsque Afficher/Masquer ¶ est désactivé.
|
||||
Typing replaces selected text La frappe remplace le texte sélectionné
|
||||
Update fields before printing Mettre à jour les champs avant l’impression
|
||||
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Remplacement
|
||||
Use the Insert key to switch overtype on and off Utiliser la touche Inser pour activer ou désactiver le mode Refrappe
|
||||
Extend Selection by Units Étendre la sélection par unités
|
||||
Animation Animation
|
||||
Animate the caret (it glides and fades) Animer le curseur (il glisse et s’estompe)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activé : le curseur glisse jusqu’à sa nouvelle position lorsque vous tapez ou utilisez les touches fléchées, et son clignotement s’estompe en douceur. Désactivé : il saute et clignote de façon nette.
|
||||
Word XML Document (*.xml) Document XML Word (*.xml)
|
||||
Word 2003 XML Document (*.xml) Document XML Word 2003 (*.xml)
|
||||
Deleted Supprimé
|
||||
Resize Table by Dragging Redimensionner le tableau en le faisant glisser
|
||||
Print Markup Imprimer les marques de révision
|
||||
@@ -1929,7 +1932,7 @@
|
||||
Lum: Lum. :
|
||||
Hex: Hex :
|
||||
Document template: Modèle de document :
|
||||
Attach… Joindre…
|
||||
Attach… Attacher…
|
||||
Automatically update document styles Mettre à jour automatiquement les styles du document
|
||||
Organizer… Organiseur…
|
||||
Documents Documents
|
||||
@@ -1968,13 +1971,13 @@
|
||||
Hierarchy Hiérarchie
|
||||
Relationship Relation
|
||||
Pyramid Pyramide
|
||||
Basic List Liste simple
|
||||
Basic Process Processus simple
|
||||
Basic Cycle Cycle simple
|
||||
Basic Pyramid Pyramide simple
|
||||
Basic Radial Radial simple
|
||||
Basic Matrix Matrice simple
|
||||
Basic Venn Venn simple
|
||||
Basic List Liste de base
|
||||
Basic Process Processus de base
|
||||
Basic Cycle Cycle de base
|
||||
Basic Pyramid Pyramide de base
|
||||
Basic Radial Radial de base
|
||||
Basic Matrix Matrice de base
|
||||
Basic Venn Venn de base
|
||||
Shows items of equal weight as blocks in rows. Affiche des éléments de même importance sous forme de blocs en lignes.
|
||||
Shows steps in order, left to right, with arrows between them. Affiche des étapes dans l’ordre, de gauche à droite, reliées par des flèches.
|
||||
Shows steps that repeat, around a circle. Affiche des étapes qui se répètent, disposées en cercle.
|
||||
@@ -2021,14 +2024,14 @@
|
||||
Expression Expression
|
||||
Not Non
|
||||
Num Occurrences Nombre d’occurrences
|
||||
Previous 1 or More 1 ou plusieurs du précédent
|
||||
Previous 1 or More 1 occurrence ou plus du précédent
|
||||
0 or More Characters 0 caractère ou plus
|
||||
Fix Position on Page Fixer la position sur la page
|
||||
Visual effects Effets visuels
|
||||
Animation effects: Effets d’animation :
|
||||
On Activés
|
||||
Off Désactivés
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Avec Désactivés, chaque changement s’affiche immédiatement.
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Défilement et zoom fluides, et menus et boutons qui apparaissent en fondu. Désactivés : chaque changement s’affiche immédiatement.
|
||||
Your system has animation effects turned on. Les effets d’animation sont activés sur votre système.
|
||||
Your system has animation effects turned off. Les effets d’animation sont désactivés sur votre système.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Votre système n’indique aucun réglage d’animation, les effets d’animation restent donc activés.
|
||||
@@ -2083,3 +2086,10 @@
|
||||
Table Properties… Propriétés du tableau…
|
||||
Search Rechercher
|
||||
Title Bar Barre de titre
|
||||
Privacy Options Options de confidentialité
|
||||
Trust Center Centre de gestion de la confidentialité
|
||||
Privacy for this document Confidentialité de ce document
|
||||
Remove my name from comments, changes and file properties when saving Supprimer mon nom des commentaires, des modifications et des propriétés du fichier à l’enregistrement
|
||||
Leave out the date and time of comments and tracked changes when saving Omettre la date et l’heure des commentaires et des modifications suivies à l’enregistrement
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Le document en cours de modification garde les noms et les dates ; seul le fichier enregistré les omet.
|
||||
Privacy Settings Paramètres de confidentialité actuels
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -191,6 +191,7 @@
|
||||
Delete Style Stílus törlése
|
||||
Delete Table Táblázat törlése
|
||||
Design Tervezés
|
||||
No document is open. Create a document or open one first. Nincs megnyitott dokumentum. Előbb hozz létre vagy nyiss meg egyet.
|
||||
development build fejlesztői kiadás
|
||||
Diagonal Átlós
|
||||
Diamond Rombusz
|
||||
@@ -1739,7 +1740,8 @@
|
||||
Formatted Formázott
|
||||
Deleted Törölve
|
||||
Interface size: Felület mérete:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. A menüszalag, a menük és a párbeszédablakok nagyítása vagy kicsinyítése a rendszer kijelzőméretezésén felül. A dokumentum nagyítása ettől független.
|
||||
Auto ({percent}%) Automatikus ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. A menüszalag, a menük és a párbeszédablakok nagyítása vagy kicsinyítése. Az Automatikus a rendszer kijelzőméretezését követi; 100% pontonként egy képernyőpixel. A dokumentum nagyítása ettől független.
|
||||
Copyright Sign Copyright jel
|
||||
Demote Lefokozás
|
||||
Edit Field Code Mezőkód szerkesztése
|
||||
@@ -2317,6 +2319,7 @@
|
||||
Previous Page Előző oldal
|
||||
Print to PDF Nyomtatás PDF-be
|
||||
Word XML Document (*.xml) Word XML-dokumentum (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML-dokumentum (*.xml)
|
||||
Use wildcards Helyettesítő karakterek használata
|
||||
Special Különleges
|
||||
Match prefix Egyezés a szó elején
|
||||
@@ -2473,3 +2476,10 @@
|
||||
Table Properties… Táblázat tulajdonságai…
|
||||
Search Keresés
|
||||
Title Bar Címsor
|
||||
Privacy Options Adatvédelmi beállítások
|
||||
Trust Center Adatvédelmi központ
|
||||
Privacy for this document A dokumentum adatvédelme
|
||||
Remove my name from comments, changes and file properties when saving Mentéskor a nevem eltávolítása a megjegyzésekből, a módosításokból és a fájl tulajdonságaiból
|
||||
Leave out the date and time of comments and tracked changes when saving Mentéskor a megjegyzések és a követett módosítások dátumának és idejének elhagyása
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. A szerkesztett dokumentum megtartja a neveket és a dátumokat; csak a mentett fájlból maradnak ki.
|
||||
Privacy Settings Jelenlegi adatvédelmi beállítások
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -93,6 +93,7 @@
|
||||
Legal style numbering Numerazione stile legale
|
||||
Link level to style: Collega livello allo stile:
|
||||
List Definition Definizione elenco
|
||||
No document is open. Create a document or open one first. Nessun documento aperto. Crea o apri prima un documento.
|
||||
Nothing Niente
|
||||
Number alignment: Allineamento numero:
|
||||
Number format Formato numero
|
||||
@@ -1330,7 +1331,8 @@
|
||||
Flip Horizontal Capovolgi orizzontalmente
|
||||
Formatted Formattato
|
||||
Interface size: Dimensioni interfaccia:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Ingrandisce o riduce la barra multifunzione, i menu e le finestre di dialogo, in aggiunta al ridimensionamento dello schermo del sistema. Lo zoom del documento è separato.
|
||||
Auto ({percent}%) Auto ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. Ingrandisce o riduce la barra multifunzione, i menu e le finestre di dialogo. Auto segue il ridimensionamento dello schermo del sistema; 100% è un pixel dello schermo per punto. Lo zoom del documento è separato.
|
||||
Title Titolo
|
||||
Subject Oggetto
|
||||
Keywords Parole chiave
|
||||
@@ -1534,6 +1536,7 @@
|
||||
Use the Insert key to switch overtype on and off Usa il tasto Ins per attivare e disattivare la sovrascrittura
|
||||
Extend Selection by Units Estendi selezione per unità
|
||||
Word XML Document (*.xml) Documento Word XML (*.xml)
|
||||
Word 2003 XML Document (*.xml) Documento Word 2003 XML (*.xml)
|
||||
Deleted Eliminato
|
||||
Print Markup Stampa revisioni
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Commenti accanto a ogni pagina e revisioni in PDF, stampe e immagini; un elenco dei commenti alla fine di pagine web, Markdown e testo.
|
||||
@@ -2459,3 +2462,10 @@ preview Title Esempio
|
||||
Automatic ({region}) Automatico ({region})
|
||||
Search Cerca
|
||||
Title Bar Barra del titolo
|
||||
Privacy Options Opzioni di privacy
|
||||
Trust Center Centro protezione
|
||||
Privacy for this document Privacy di questo documento
|
||||
Remove my name from comments, changes and file properties when saving Rimuovi il mio nome da commenti, modifiche e proprietà del file al salvataggio
|
||||
Leave out the date and time of comments and tracked changes when saving Ometti data e ora di commenti e revisioni al salvataggio
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. Il documento che stai modificando conserva nomi e date; solo il file salvato li omette.
|
||||
Privacy Settings Impostazioni di privacy attuali
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -120,6 +120,7 @@
|
||||
More templates → その他のテンプレート →
|
||||
New comments and tracked changes are recorded under this name and these initials. 新しいコメントと変更履歴は、この名前とイニシャルで記録されます。
|
||||
New word 新しい単語
|
||||
No document is open. Create a document or open one first. 開いている文書がありません。先に文書を作成するか開いてください。
|
||||
No templates match your search. 検索に一致するテンプレートはありません。
|
||||
No words yet. Add to Dictionary on a misspelled word puts it here. まだ単語はありません。スペルミスの単語で [辞書に追加] を選ぶと、ここに追加されます。
|
||||
Nothing なし
|
||||
@@ -950,6 +951,7 @@
|
||||
Word Count 文字カウント
|
||||
Word document (*.docx) Word 文書 (*.docx)
|
||||
Word XML Document (*.xml) Word XML ドキュメント (*.xml)
|
||||
Word 2003 XML Document (*.xml) Word 2003 XML ドキュメント (*.xml)
|
||||
Word Left 前の単語へ
|
||||
Word Right 次の単語へ
|
||||
WordArt ワードアート
|
||||
@@ -1419,7 +1421,8 @@
|
||||
Formatted 書式変更
|
||||
Deleted 削除
|
||||
Interface size: 表示サイズ:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。
|
||||
Auto ({percent}%) 自動 ({percent}%)
|
||||
Makes the ribbon, menus and dialogs larger or smaller. Auto follows your system's display scaling; 100% is one screen pixel per point. Document zoom is separate. リボン、メニュー、ダイアログを拡大または縮小します。自動はシステムの表示倍率に従います。100% は 1 ポイントあたり画面の 1 ピクセルです。文書のズームは別に設定します。
|
||||
Resize Table by Dragging ドラッグで表のサイズを変更
|
||||
Print Markup 変更履歴とコメントを印刷
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF、印刷、画像では各ページの横にコメントと変更履歴を表示し、Web ページ、Markdown、テキストでは末尾にコメント一覧を付けます。
|
||||
@@ -2075,3 +2078,10 @@
|
||||
Start-up 起動
|
||||
Show the Start screen when WordCraft starts WordCraft の起動時にスタート画面を表示する
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. オン: WordCraft は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。
|
||||
Privacy Options プライバシー オプション
|
||||
Trust Center セキュリティ センター
|
||||
Privacy for this document このドキュメントのプライバシー
|
||||
Remove my name from comments, changes and file properties when saving 保存時にコメント、変更、ファイルのプロパティから自分の名前を削除する
|
||||
Leave out the date and time of comments and tracked changes when saving 保存時にコメントと変更履歴の日時を含めない
|
||||
The document you are editing keeps the names and dates; only the saved file leaves them out. 編集中のドキュメントには名前と日時が残ります。保存したファイルにだけ含まれません。
|
||||
Privacy Settings 現在のプライバシー設定
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 564 and column 13.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user