Compare commits
65 Commits
perf/pdf-b
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
159b84f54c | |
|
|
fbacffd282 | |
|
|
dbfa2af68a | |
|
|
b9899f410b | |
|
|
b1d0aab486 | |
|
|
a381c93e8d | |
|
|
5baaf97413 | |
|
|
8826025f18 | |
|
|
e989fc2b3d | |
|
|
591cbb6f68 | |
|
|
7af79cd9f0 | |
|
|
0f9dc5c6ae | |
|
|
9d1e207c78 | |
|
|
be7474566b | |
|
|
1350cb959b | |
|
|
7dfa6b0ee2 | |
|
|
36af036233 | |
|
|
7f1085fbbd | |
|
|
35b3600ea5 | |
|
|
f0d47b6090 | |
|
|
1695705241 | |
|
|
691b136811 | |
|
|
7ee3cea875 | |
|
|
3e7f355e5a | |
|
|
cd6b2bbe46 | |
|
|
03274b3b4f | |
|
|
8c8d5611a4 | |
|
|
f24a5b22b2 | |
|
|
284818b467 | |
|
|
994162fdff | |
|
|
d863811511 | |
|
|
681a57fc1a | |
|
|
8ca285ca9d | |
|
|
3ede32e80e | |
|
|
97ade895d7 | |
|
|
05e5ff0e0d | |
|
|
672522b2ed | |
|
|
55bfc33418 | |
|
|
5383d80f75 | |
|
|
8c67ef7c28 | |
|
|
fc52079f58 | |
|
|
c9079196db | |
|
|
7d37b14679 | |
|
|
5aa8151a53 | |
|
|
74548cff57 | |
|
|
9f0776a70b | |
|
|
541e10e23a | |
|
|
2f833b6acf | |
|
|
2e66cbb261 | |
|
|
99561097d5 | |
|
|
45abb5ded3 | |
|
|
906e0f8aca | |
|
|
64f656a8bc | |
|
|
6f35313f68 | |
|
|
1677c2d9cf | |
|
|
26cdcfd4c6 | |
|
|
fccc09e4c9 | |
|
|
90515abd1e | |
|
|
0acf6bd512 | |
|
|
912d49f38f | |
|
|
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
|
||||
|
|
@ -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.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5113,7 +5130,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"wordcraft-doc",
|
||||
|
|
@ -5125,11 +5142,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -5141,7 +5158,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"image",
|
||||
|
|
@ -5156,7 +5173,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
|
|
@ -5178,7 +5195,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
|
|
@ -5204,7 +5221,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
|
|
@ -5217,7 +5234,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
|
|
@ -5230,14 +5247,14 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"js-sys",
|
||||
|
|
@ -5256,7 +5273,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
|
|
@ -5268,7 +5285,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
|
|
@ -5276,7 +5293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
|
|
@ -5296,7 +5313,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-proof"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
|
|
@ -5305,7 +5322,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
|
|
@ -5319,8 +5336,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
|
|
@ -5343,7 +5361,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5362,7 +5380,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -5475,7 +5493,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
|
|
@ -235,6 +235,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
|||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
The **32-bit** build uses OpenGL even when a DirectX 12 graphics card is present. On 32-bit
|
||||
Windows, creating a DirectX 12 graphics pipeline fails immediately and the app exits before the
|
||||
window appears. `WGPU_BACKEND=vulkan` (or `dx12`) still overrides that.
|
||||
|
||||
**If the app quits right after its window appears with "Parent device is lost" in the log:**
|
||||
WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no
|
||||
`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603).
|
||||
|
|
|
|||
73
ROADMAP.md
73
ROADMAP.md
|
|
@ -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-12 · **Change:** trivial (Turkish interface catalog completed, #774; previously trivial: Hungarian interface landed; previously trivial: Quick Access Toolbar KeyTips and a saved enable/disable option, #620; previously trivial: new documents follow the system region, #838; previously minor: Polish interface and Polish proofing; 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-11 · **Change:** trivial (progress log: 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
|
||||
|
|
@ -14,7 +16,7 @@ agent control. This page is the summary; the assessment is
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **484 / 499 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
|
|
@ -132,12 +134,54 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
## Progress log
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
|
||||
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
|
||||
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
|
||||
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
|
||||
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
|
||||
| 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-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 |
|
||||
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
|
||||
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
|
||||
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
|
||||
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
|
||||
| 2026-10-12 | DOCX headings numbered like Word when a heading style gives only a list level (6.4.5, not 6.15) |
|
||||
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
|
||||
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
|
||||
| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) |
|
||||
| 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" |
|
||||
| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together |
|
||||
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
|
||||
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
|
||||
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
|
||||
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
|
||||
| 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) |
|
||||
|
|
@ -147,6 +191,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-12 | Portuguese proofing: pt-BR spelling against the installed Hunspell `pt_BR` dictionary (VERO; read at runtime, not bundled) — the affix engine grew `FLAG UTF-8`, 128 flag bits, continuation classes and `FORBIDDENWORD` to read it — suggestions tuned for Portuguese (missing accents first), conservative grammar rules with Portuguese messages, TeX Portuguese hyphenation patterns (BSD 3-clause, attributed); Review › Language offers Português (Brasil); a pt-br interface writes new documents in pt-BR |
|
||||
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
|
||||
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
|
||||
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
|
||||
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
|
||||
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
|
||||
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
|
||||
|
|
@ -158,6 +203,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 |
|
||||
|
|
@ -176,6 +222,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
|
||||
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
|
||||
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
|
||||
| 2026-10-12 | Selection bar (#856): in the left margin beside body text a click selects the line, a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; dragging there selects whole lines and Shift+click extends by lines; the pointer turns to an arrow. Right margin for right-to-left paragraphs not yet |
|
||||
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
|
||||
| 2026-10-11 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
|
||||
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
|
||||
|
|
@ -226,18 +273,39 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
|
||||
| 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 |
|
||||
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
|
||||
| 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-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 |
|
||||
| 2026-10-12 | trivial | Progress log: new documents follow the system region (#838) |
|
||||
| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage |
|
||||
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
|
||||
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
|
||||
| 2026-10-11 | trivial | Progress log: German interface (#574) |
|
||||
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
|
||||
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
|
||||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
| 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) |
|
||||
|
|
@ -248,6 +316,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
|||
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
|
||||
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
|
||||
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
|
||||
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
|
||||
| 2026-10-11 | trivial | Progress log: content controls (#381) |
|
||||
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
|
||||
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
|
||||
|
|
|
|||
|
|
@ -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,15 +54,16 @@ pub fn start() {
|
|||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
let doc = if query().contains("sample") {
|
||||
wordcraft_engine::sample::sample_document()
|
||||
} else {
|
||||
wordcraft_engine::blank_document(®ion)
|
||||
};
|
||||
let sample = query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
|
||||
app.autosave = false;
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, saves }))
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -20,18 +20,28 @@ pub struct Dx12Adapter {
|
|||
/// The backends to initialize on Windows.
|
||||
///
|
||||
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
|
||||
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
|
||||
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
|
||||
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
|
||||
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
|
||||
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
|
||||
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
|
||||
/// adapter, so it's only picked when GL has nothing better.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_graphics` is set and `dx12_adapters` (what a
|
||||
/// DirectX 12-only probe found) contains a usable hardware GPU. Without one, OpenGL is the way to
|
||||
/// the GPU: graphics chips whose driver has no DirectX 12 support (Intel 4th generation on Windows
|
||||
/// 11, #316) still have an OpenGL driver, and GL can't reach the AMD driver crash described in
|
||||
/// [`configure_backends`], because an AMD GPU with a driver shows up as a DirectX 12 adapter and
|
||||
/// keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays enabled next to GL as a last
|
||||
/// resort for machines without an OpenGL driver either (virtual machines): wgpu prefers any GPU
|
||||
/// over a software adapter, so it's only picked when GL has nothing better.
|
||||
///
|
||||
/// `dx12_graphics` is false on 32-bit Windows. wgpu creates the graphics pipeline through
|
||||
/// `ID3D12Device2::CreatePipelineState`, and that stream is aligned to 8 bytes. The 32-bit runtime
|
||||
/// expects pointer alignment (4), so creation returns `E_INVALIDARG` ("The parameter is incorrect")
|
||||
/// and the process exits before the first frame. Compute pipelines use the older API and succeed,
|
||||
/// which is why startup gets as far as the egui pipeline and then quits. OpenGL does not use that
|
||||
/// stream.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter], dx12_graphics: bool) -> Backends {
|
||||
if let Some(backends) = backend_override {
|
||||
return backends;
|
||||
}
|
||||
if !dx12_graphics {
|
||||
return Backends::GL;
|
||||
}
|
||||
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
|
||||
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
|
||||
Backends::DX12
|
||||
|
|
@ -94,9 +104,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
|||
backends
|
||||
}
|
||||
None => {
|
||||
let adapters = probe_dx12(options);
|
||||
let backends = choose_backends(None, &adapters);
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
// 32-bit: skip the DirectX 12 probe. A capable GPU would be selected, then
|
||||
// CreatePipelineState fails and the app exits with no window.
|
||||
let dx12_graphics = cfg!(not(target_arch = "x86"));
|
||||
let adapters = if dx12_graphics { probe_dx12(options) } else { Vec::new() };
|
||||
let backends = choose_backends(None, &adapters, dx12_graphics);
|
||||
if dx12_graphics {
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
} else {
|
||||
log::info!("graphics: 32-bit Windows uses OpenGL; DirectX 12 graphics pipeline creation fails");
|
||||
}
|
||||
backends
|
||||
}
|
||||
};
|
||||
|
|
@ -157,7 +174,7 @@ mod tests {
|
|||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -165,8 +182,8 @@ mod tests {
|
|||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
// Whatever the probe would have found, the override wins (the probe doesn't run then).
|
||||
assert_eq!(choose_backends(Some(backends), &[]), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &[], true), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
|
||||
assert_eq!(configured_backends(backends), backends);
|
||||
}
|
||||
}
|
||||
|
|
@ -192,13 +209,23 @@ mod tests {
|
|||
fn gl_when_dx12_has_no_usable_gpu() {
|
||||
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[]), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[], true), Backends::GL);
|
||||
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
|
||||
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &weak_gpu, true), Backends::GL);
|
||||
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
|
||||
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
|
||||
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &mixed, true), Backends::DX12);
|
||||
}
|
||||
|
||||
/// 32-bit Windows: a usable DirectX 12 GPU still starts on OpenGL. `WGPU_BACKEND` still wins.
|
||||
#[test]
|
||||
fn x86_uses_opengl_even_with_a_usable_gpu() {
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(choose_backends(None, &gpu, false), Backends::GL);
|
||||
assert_eq!(choose_backends(Some(Backends::DX12), &gpu, false), Backends::DX12);
|
||||
assert_eq!(choose_backends(Some(Backends::VULKAN), &[], false), Backends::VULKAN);
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu, false)), Backends::GL);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,13 +199,14 @@ 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)),
|
||||
|
|
@ -274,8 +281,8 @@ fn main() -> eframe::Result {
|
|||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
|
||||
// DirectX 12 has no GPU driver, and compile DirectX 12 shaders with FXC, which needs no
|
||||
// DLL of ours (#603; see graphics.rs).
|
||||
// DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
load_user_fonts();
|
||||
|
|
@ -329,6 +336,12 @@ fn main() -> eframe::Result {
|
|||
app = app.with_control(rx);
|
||||
key_file = file;
|
||||
}
|
||||
// Nothing to open (no files, no sample, no control channel driving the editor): start
|
||||
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
|
||||
// macOS hands over later open on top of it and close it (`apple_events`).
|
||||
if files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
log::warn!("{f}: {e}");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,3 +64,11 @@ 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"
|
||||
[people.odedigo]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,520 @@
|
|||
//! Language-aware sorting of text: a small, pure-Rust collation.
|
||||
//!
|
||||
//! Every letter gets a *primary* weight (its place in the alphabet) and a *secondary* one (its
|
||||
//! diacritic). Strings compare by primary weights first, then by secondary ones, so `é` sorts
|
||||
//! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
|
||||
//! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
|
||||
//! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
|
||||
//! first letter. The per-language tables follow the published alphabets of those languages.
|
||||
//! Text without diacritics sorts exactly as a case-insensitive code-point compare.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The diacritic of a precomposed lowercase Latin letter: `(base letters, mark)`. Marks: 1 acute,
|
||||
/// 2 grave, 3 circumflex, 4 diaeresis, 5 tilde, 6 ring, 7 caron, 8 cedilla, 9 ogonek, 10 dot,
|
||||
/// 11 stroke, 12 breve, 13 macron, 14 double acute, 15 comma below, 16 ligature, 17 dotless,
|
||||
/// 18 eth.
|
||||
const DECOMP: &[(char, &str, u16)] = &[
|
||||
('à', "a", 2),
|
||||
('á', "a", 1),
|
||||
('â', "a", 3),
|
||||
('ã', "a", 5),
|
||||
('ä', "a", 4),
|
||||
('å', "a", 6),
|
||||
('ā', "a", 13),
|
||||
('ă', "a", 12),
|
||||
('ą', "a", 9),
|
||||
('æ', "ae", 16),
|
||||
('ç', "c", 8),
|
||||
('ć', "c", 1),
|
||||
('ĉ', "c", 3),
|
||||
('ċ', "c", 10),
|
||||
('č', "c", 7),
|
||||
('ď', "d", 7),
|
||||
('đ', "d", 11),
|
||||
('ð', "d", 18),
|
||||
('è', "e", 2),
|
||||
('é', "e", 1),
|
||||
('ê', "e", 3),
|
||||
('ë', "e", 4),
|
||||
('ē', "e", 13),
|
||||
('ĕ', "e", 12),
|
||||
('ė', "e", 10),
|
||||
('ę', "e", 9),
|
||||
('ě', "e", 7),
|
||||
('ĝ', "g", 3),
|
||||
('ğ', "g", 12),
|
||||
('ġ', "g", 10),
|
||||
('ģ', "g", 8),
|
||||
('ĥ', "h", 3),
|
||||
('ħ', "h", 11),
|
||||
('ì', "i", 2),
|
||||
('í', "i", 1),
|
||||
('î', "i", 3),
|
||||
('ï', "i", 4),
|
||||
('ĩ', "i", 5),
|
||||
('ī', "i", 13),
|
||||
('ĭ', "i", 12),
|
||||
('į', "i", 9),
|
||||
('ı', "i", 17),
|
||||
('ij', "ij", 16),
|
||||
('ĵ', "j", 3),
|
||||
('ķ', "k", 8),
|
||||
('ĺ', "l", 1),
|
||||
('ļ', "l", 8),
|
||||
('ľ', "l", 7),
|
||||
('ŀ', "l", 10),
|
||||
('ł', "l", 11),
|
||||
('ñ', "n", 5),
|
||||
('ń', "n", 1),
|
||||
('ņ', "n", 8),
|
||||
('ň', "n", 7),
|
||||
('ò', "o", 2),
|
||||
('ó', "o", 1),
|
||||
('ô', "o", 3),
|
||||
('õ', "o", 5),
|
||||
('ö', "o", 4),
|
||||
('ø', "o", 11),
|
||||
('ō', "o", 13),
|
||||
('ŏ', "o", 12),
|
||||
('ő', "o", 14),
|
||||
('œ', "oe", 16),
|
||||
('ŕ', "r", 1),
|
||||
('ŗ', "r", 8),
|
||||
('ř', "r", 7),
|
||||
('ś', "s", 1),
|
||||
('ŝ', "s", 3),
|
||||
('ş', "s", 8),
|
||||
('š', "s", 7),
|
||||
('ș', "s", 15),
|
||||
('ß', "ss", 16),
|
||||
('ţ', "t", 8),
|
||||
('ť', "t", 7),
|
||||
('ŧ', "t", 11),
|
||||
('ț', "t", 15),
|
||||
('ù', "u", 2),
|
||||
('ú', "u", 1),
|
||||
('û', "u", 3),
|
||||
('ü', "u", 4),
|
||||
('ũ', "u", 5),
|
||||
('ū', "u", 13),
|
||||
('ŭ', "u", 12),
|
||||
('ů', "u", 6),
|
||||
('ű', "u", 14),
|
||||
('ų', "u", 9),
|
||||
('ŵ', "w", 3),
|
||||
('ý', "y", 1),
|
||||
('ÿ', "y", 4),
|
||||
('ŷ', "y", 3),
|
||||
('ź', "z", 1),
|
||||
('ż', "z", 10),
|
||||
('ž', "z", 7),
|
||||
];
|
||||
|
||||
/// A letter (or letter group) a language sorts as a letter of its own: right after `after`
|
||||
/// (`rank` places after it), with secondary weight `sec` among letters of the same place.
|
||||
struct Tailor {
|
||||
seq: &'static str,
|
||||
after: char,
|
||||
rank: u32,
|
||||
sec: u16,
|
||||
}
|
||||
|
||||
const fn t(seq: &'static str, after: char, rank: u32) -> Tailor {
|
||||
Tailor { seq, after, rank, sec: 0 }
|
||||
}
|
||||
|
||||
const fn ts(seq: &'static str, after: char, rank: u32, sec: u16) -> Tailor {
|
||||
Tailor { seq, after, rank, sec }
|
||||
}
|
||||
|
||||
const PL: &[Tailor] =
|
||||
&[t("ą", 'a', 1), t("ć", 'c', 1), t("ę", 'e', 1), t("ł", 'l', 1), t("ń", 'n', 1), t("ó", 'o', 1), t("ś", 's', 1), t("ź", 'z', 1), t("ż", 'z', 2)];
|
||||
const CS: &[Tailor] = &[t("č", 'c', 1), t("ch", 'h', 1), t("ř", 'r', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const SK: &[Tailor] = &[t("ä", 'a', 1), t("č", 'c', 1), t("ch", 'h', 1), t("ô", 'o', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const HU: &[Tailor] = &[
|
||||
t("cs", 'c', 1),
|
||||
t("dz", 'd', 1),
|
||||
t("dzs", 'd', 2),
|
||||
t("gy", 'g', 1),
|
||||
t("ly", 'l', 1),
|
||||
t("ny", 'n', 1),
|
||||
t("ö", 'o', 1),
|
||||
ts("ő", 'o', 1, 14),
|
||||
t("sz", 's', 1),
|
||||
t("ty", 't', 1),
|
||||
t("ü", 'u', 1),
|
||||
ts("ű", 'u', 1, 14),
|
||||
t("zs", 'z', 1),
|
||||
];
|
||||
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
|
||||
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
|
||||
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
|
||||
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
|
||||
const HR: &[Tailor] =
|
||||
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const RO: &[Tailor] = &[t("ă", 'a', 1), t("â", 'a', 2), t("î", 'i', 1), t("ș", 's', 1), ts("ş", 's', 1, 8), t("ț", 't', 1), ts("ţ", 't', 1, 8)];
|
||||
const ET: &[Tailor] = &[t("š", 's', 1), t("z", 's', 2), t("ž", 's', 3), t("õ", 'w', 1), t("ä", 'w', 2), t("ö", 'w', 3), t("ü", 'w', 4)];
|
||||
const LT: &[Tailor] = &[t("č", 'c', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const LV: &[Tailor] = &[t("č", 'c', 1), t("ģ", 'g', 1), t("ķ", 'k', 1), t("ļ", 'l', 1), t("ņ", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const IS: &[Tailor] = &[
|
||||
t("á", 'a', 1),
|
||||
t("ð", 'd', 1),
|
||||
t("é", 'e', 1),
|
||||
t("í", 'i', 1),
|
||||
t("ó", 'o', 1),
|
||||
t("ú", 'u', 1),
|
||||
t("ý", 'y', 1),
|
||||
t("þ", 'z', 1),
|
||||
t("æ", 'z', 2),
|
||||
t("ö", 'z', 3),
|
||||
];
|
||||
|
||||
/// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
|
||||
pub fn language(lang: Option<&str>) -> String {
|
||||
lang.and_then(|l| l.split(['-', '_']).next()).unwrap_or("").trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
match language(lang).as_str() {
|
||||
"pl" => PL,
|
||||
"cs" => CS,
|
||||
"sk" => SK,
|
||||
"hu" => HU,
|
||||
"sv" | "fi" => SV,
|
||||
"da" | "nb" | "nn" | "no" => DA,
|
||||
"es" | "gl" => ES,
|
||||
"tr" | "az" => TR,
|
||||
"hr" | "bs" | "sr" => HR,
|
||||
"ro" => RO,
|
||||
"et" => ET,
|
||||
"lt" => LT,
|
||||
"lv" => LV,
|
||||
"is" => IS,
|
||||
"sl" => LT,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode
|
||||
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
|
||||
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
|
||||
const COMPOSE: &[(char, char, char)] = &[
|
||||
('a', '\u{300}', 'à'),
|
||||
('a', '\u{301}', 'á'),
|
||||
('a', '\u{302}', 'â'),
|
||||
('a', '\u{303}', 'ã'),
|
||||
('a', '\u{304}', 'ā'),
|
||||
('a', '\u{306}', 'ă'),
|
||||
('a', '\u{307}', 'ȧ'),
|
||||
('a', '\u{308}', 'ä'),
|
||||
('a', '\u{30a}', 'å'),
|
||||
('a', '\u{30c}', 'ǎ'),
|
||||
('a', '\u{328}', 'ą'),
|
||||
('b', '\u{307}', 'ḃ'),
|
||||
('c', '\u{301}', 'ć'),
|
||||
('c', '\u{302}', 'ĉ'),
|
||||
('c', '\u{307}', 'ċ'),
|
||||
('c', '\u{30c}', 'č'),
|
||||
('c', '\u{327}', 'ç'),
|
||||
('d', '\u{307}', 'ḋ'),
|
||||
('d', '\u{30c}', 'ď'),
|
||||
('d', '\u{327}', 'ḑ'),
|
||||
('e', '\u{300}', 'è'),
|
||||
('e', '\u{301}', 'é'),
|
||||
('e', '\u{302}', 'ê'),
|
||||
('e', '\u{303}', 'ẽ'),
|
||||
('e', '\u{304}', 'ē'),
|
||||
('e', '\u{306}', 'ĕ'),
|
||||
('e', '\u{307}', 'ė'),
|
||||
('e', '\u{308}', 'ë'),
|
||||
('e', '\u{30c}', 'ě'),
|
||||
('e', '\u{327}', 'ȩ'),
|
||||
('e', '\u{328}', 'ę'),
|
||||
('f', '\u{307}', 'ḟ'),
|
||||
('g', '\u{301}', 'ǵ'),
|
||||
('g', '\u{302}', 'ĝ'),
|
||||
('g', '\u{304}', 'ḡ'),
|
||||
('g', '\u{306}', 'ğ'),
|
||||
('g', '\u{307}', 'ġ'),
|
||||
('g', '\u{30c}', 'ǧ'),
|
||||
('g', '\u{327}', 'ģ'),
|
||||
('h', '\u{302}', 'ĥ'),
|
||||
('h', '\u{307}', 'ḣ'),
|
||||
('h', '\u{308}', 'ḧ'),
|
||||
('h', '\u{30c}', 'ȟ'),
|
||||
('h', '\u{327}', 'ḩ'),
|
||||
('i', '\u{300}', 'ì'),
|
||||
('i', '\u{301}', 'í'),
|
||||
('i', '\u{302}', 'î'),
|
||||
('i', '\u{303}', 'ĩ'),
|
||||
('i', '\u{304}', 'ī'),
|
||||
('i', '\u{306}', 'ĭ'),
|
||||
('i', '\u{308}', 'ï'),
|
||||
('i', '\u{30c}', 'ǐ'),
|
||||
('i', '\u{328}', 'į'),
|
||||
('j', '\u{302}', 'ĵ'),
|
||||
('j', '\u{30c}', 'ǰ'),
|
||||
('k', '\u{301}', 'ḱ'),
|
||||
('k', '\u{30c}', 'ǩ'),
|
||||
('k', '\u{327}', 'ķ'),
|
||||
('l', '\u{301}', 'ĺ'),
|
||||
('l', '\u{30c}', 'ľ'),
|
||||
('l', '\u{327}', 'ļ'),
|
||||
('m', '\u{301}', 'ḿ'),
|
||||
('m', '\u{307}', 'ṁ'),
|
||||
('n', '\u{300}', 'ǹ'),
|
||||
('n', '\u{301}', 'ń'),
|
||||
('n', '\u{303}', 'ñ'),
|
||||
('n', '\u{307}', 'ṅ'),
|
||||
('n', '\u{30c}', 'ň'),
|
||||
('n', '\u{327}', 'ņ'),
|
||||
('o', '\u{300}', 'ò'),
|
||||
('o', '\u{301}', 'ó'),
|
||||
('o', '\u{302}', 'ô'),
|
||||
('o', '\u{303}', 'õ'),
|
||||
('o', '\u{304}', 'ō'),
|
||||
('o', '\u{306}', 'ŏ'),
|
||||
('o', '\u{307}', 'ȯ'),
|
||||
('o', '\u{308}', 'ö'),
|
||||
('o', '\u{30b}', 'ő'),
|
||||
('o', '\u{30c}', 'ǒ'),
|
||||
('o', '\u{328}', 'ǫ'),
|
||||
('p', '\u{301}', 'ṕ'),
|
||||
('p', '\u{307}', 'ṗ'),
|
||||
('r', '\u{301}', 'ŕ'),
|
||||
('r', '\u{307}', 'ṙ'),
|
||||
('r', '\u{30c}', 'ř'),
|
||||
('r', '\u{327}', 'ŗ'),
|
||||
('s', '\u{301}', 'ś'),
|
||||
('s', '\u{302}', 'ŝ'),
|
||||
('s', '\u{307}', 'ṡ'),
|
||||
('s', '\u{30c}', 'š'),
|
||||
('s', '\u{326}', 'ș'),
|
||||
('s', '\u{327}', 'ş'),
|
||||
('t', '\u{307}', 'ṫ'),
|
||||
('t', '\u{308}', 'ẗ'),
|
||||
('t', '\u{30c}', 'ť'),
|
||||
('t', '\u{326}', 'ț'),
|
||||
('t', '\u{327}', 'ţ'),
|
||||
('u', '\u{300}', 'ù'),
|
||||
('u', '\u{301}', 'ú'),
|
||||
('u', '\u{302}', 'û'),
|
||||
('u', '\u{303}', 'ũ'),
|
||||
('u', '\u{304}', 'ū'),
|
||||
('u', '\u{306}', 'ŭ'),
|
||||
('u', '\u{308}', 'ü'),
|
||||
('u', '\u{30a}', 'ů'),
|
||||
('u', '\u{30b}', 'ű'),
|
||||
('u', '\u{30c}', 'ǔ'),
|
||||
('u', '\u{328}', 'ų'),
|
||||
('v', '\u{303}', 'ṽ'),
|
||||
('w', '\u{300}', 'ẁ'),
|
||||
('w', '\u{301}', 'ẃ'),
|
||||
('w', '\u{302}', 'ŵ'),
|
||||
('w', '\u{307}', 'ẇ'),
|
||||
('w', '\u{308}', 'ẅ'),
|
||||
('w', '\u{30a}', 'ẘ'),
|
||||
('x', '\u{307}', 'ẋ'),
|
||||
('x', '\u{308}', 'ẍ'),
|
||||
('y', '\u{300}', 'ỳ'),
|
||||
('y', '\u{301}', 'ý'),
|
||||
('y', '\u{302}', 'ŷ'),
|
||||
('y', '\u{303}', 'ỹ'),
|
||||
('y', '\u{304}', 'ȳ'),
|
||||
('y', '\u{307}', 'ẏ'),
|
||||
('y', '\u{308}', 'ÿ'),
|
||||
('y', '\u{30a}', 'ẙ'),
|
||||
('z', '\u{301}', 'ź'),
|
||||
('z', '\u{302}', 'ẑ'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('z', '\u{30c}', 'ž'),
|
||||
];
|
||||
|
||||
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
|
||||
fn lower_composed(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.to_lowercase().chars() {
|
||||
if let Some(prev) = out.chars().next_back()
|
||||
&& let Some((_, _, composed)) = COMPOSE.iter().find(|(b, m, _)| *b == prev && *m == c)
|
||||
{
|
||||
out.pop();
|
||||
out.push(*composed);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weight(c: char, rank: u32) -> u32 {
|
||||
((c as u32) << 4).saturating_add(rank)
|
||||
}
|
||||
|
||||
/// One collation element: primary weight, secondary weight, the text it covers.
|
||||
struct Elem<'a> {
|
||||
primary: Vec<u32>,
|
||||
sec: u16,
|
||||
text: &'a str,
|
||||
/// Set when the text is a letter of its own in the language (shown as is in headings).
|
||||
own: bool,
|
||||
}
|
||||
|
||||
/// The next collation element of `rest` (lowercase), and its length in bytes.
|
||||
fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
|
||||
if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
|
||||
let text = rest.get(..r.seq.len())?;
|
||||
return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
|
||||
}
|
||||
let c = rest.chars().next()?;
|
||||
let n = c.len_utf8();
|
||||
let text = rest.get(..n)?;
|
||||
if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
|
||||
return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
|
||||
}
|
||||
// A combining mark adds a secondary difference to the letter before it.
|
||||
if ('\u{300}'..='\u{36f}').contains(&c) {
|
||||
return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
|
||||
}
|
||||
Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
|
||||
}
|
||||
|
||||
/// A sort key: compare keys to sort strings in a language's order. Equal keys mean the strings
|
||||
/// differ at most in letter case.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||
pub struct SortKey {
|
||||
primary: Vec<u32>,
|
||||
secondary: Vec<u16>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// The sort key of `s` for language `lang` (a BCP 47 tag such as `pl-PL`; `None` sorts like
|
||||
/// English).
|
||||
pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
|
||||
let text = lower_composed(s);
|
||||
let rules = tailoring(lang);
|
||||
let mut key = SortKey::default();
|
||||
let mut i = 0;
|
||||
while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
|
||||
let Some((e, n)) = next_elem(rest, rules) else { break };
|
||||
i += n.max(1);
|
||||
if e.primary.is_empty() {
|
||||
if let Some(last) = key.secondary.last_mut() {
|
||||
*last = last.saturating_add(e.sec);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (k, w) in e.primary.iter().enumerate() {
|
||||
key.primary.push(*w);
|
||||
key.secondary.push(if k == 0 { e.sec } else { 0 });
|
||||
}
|
||||
}
|
||||
key.text = text;
|
||||
key
|
||||
}
|
||||
|
||||
/// Compare two strings in the order of language `lang`, ignoring case.
|
||||
pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
|
||||
sort_key(a, lang).cmp(&sort_key(b, lang))
|
||||
}
|
||||
|
||||
/// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
|
||||
/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
|
||||
pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
|
||||
let low = lower_composed(s.trim_start());
|
||||
let (e, _) = next_elem(&low, tailoring(lang))?;
|
||||
let shown = if e.own || e.sec == 0 {
|
||||
e.text.to_string()
|
||||
} else {
|
||||
DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
|
||||
};
|
||||
let mut cs = shown.chars();
|
||||
let first = cs.next()?;
|
||||
Some(first.to_uppercase().chain(cs).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sorted(words: &[&str], lang: &str) -> Vec<String> {
|
||||
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
|
||||
v.sort_by(|a, b| collate(a, b, Some(lang)));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_letters_follow_their_base_letter() {
|
||||
assert_eq!(sorted(&["Zakopane", "Łódź", "Kraków"], "pl-PL"), ["Kraków", "Łódź", "Zakopane"]);
|
||||
assert_eq!(sorted(&["zebra", "ćma", "dom"], "pl-PL"), ["ćma", "dom", "zebra"]);
|
||||
assert_eq!(sorted(&["żaba", "źle", "zebra", "ząb", "lato", "łapa", "mama"], "pl"), ["lato", "łapa", "mama", "ząb", "zebra", "źle", "żaba"]);
|
||||
assert_eq!(sorted(&["b", "ą", "a"], "pl"), ["a", "ą", "b"]);
|
||||
// Every Polish letter is a letter of its own, after its base letter (review of #844).
|
||||
assert_eq!(sorted(&["Malbork", "Łódź", "Lublin"], "pl"), ["Lublin", "Łódź", "Malbork"]);
|
||||
assert_eq!(sorted(&["ósmy", "owca", "pies"], "pl"), ["owca", "ósmy", "pies"]);
|
||||
assert_eq!(sorted(&["ściana", "sum", "tak"], "pl"), ["sum", "ściana", "tak"]);
|
||||
assert_eq!(sorted(&["ęsi", "ezop", "fala"], "pl"), ["ezop", "ęsi", "fala"]);
|
||||
assert_eq!(sorted(&["ńa", "nz", "oko"], "pl"), ["nz", "ńa", "oko"]);
|
||||
assert_eq!(heading_letter("ósmy", Some("pl")).as_deref(), Some("Ó"));
|
||||
assert_eq!(heading_letter("ńa", Some("pl")).as_deref(), Some("Ń"));
|
||||
// Decomposed letters (`z` + combining dot above, as macOS file names write `ż`) sort like
|
||||
// the composed ones.
|
||||
assert_eq!(collate("z\u{307}aba", "żaba", Some("pl-PL")), Ordering::Equal);
|
||||
assert_eq!(collate("z\u{307}aba", "zebra", Some("pl-PL")), Ordering::Greater);
|
||||
assert_eq!(heading_letter("Z\u{307}aba", Some("pl")).as_deref(), Some("Ż"));
|
||||
assert_eq!(collate("a\u{328}", "ą", Some("pl")), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swedish_puts_a_ring_and_umlauts_after_z() {
|
||||
assert_eq!(sorted(&["ödla", "zebra", "äpple", "åka", "apa"], "sv-SE"), ["apa", "zebra", "åka", "äpple", "ödla"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_sorts_umlauts_with_their_base_letter() {
|
||||
assert_eq!(sorted(&["Zug", "Äpfel", "Apfel", "Bach"], "de-DE"), ["Apfel", "Äpfel", "Bach", "Zug"]);
|
||||
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_n_tilde_after_n() {
|
||||
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_slovak_hungarian_alphabets() {
|
||||
assert_eq!(sorted(&["chata", "hrad", "ihla", "cesta", "čaj", "dům"], "cs-CZ"), ["cesta", "čaj", "dům", "hrad", "chata", "ihla"]);
|
||||
assert_eq!(sorted(&["ôsmy", "oko", "pes", "ä", "b"], "sk"), ["ä", "b", "oko", "ôsmy", "pes"]);
|
||||
assert_eq!(sorted(&["csak", "cukor", "dob", "szó", "sűrű", "tál"], "hu-HU"), ["cukor", "csak", "dob", "sűrű", "szó", "tál"]);
|
||||
// ö and ő are one letter after o, told apart by the accent only.
|
||||
assert_eq!(sorted(&["ötös", "őr", "pálya", "olló", "ör"], "hu"), ["olló", "ör", "őr", "ötös", "pálya"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_keeps_case_insensitive_order_and_folds_accents() {
|
||||
assert_eq!(sorted(&["rex", "résumé", "resume", "Apple", "banana"], "en-US"), ["Apple", "banana", "resume", "résumé", "rex"]);
|
||||
assert_eq!(collate("Łódź", "Lodz", None), Ordering::Greater);
|
||||
assert_eq!(collate("Łódź", "Lodzia", None), Ordering::Less);
|
||||
assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_letters() {
|
||||
assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
|
||||
assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
|
||||
assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
|
||||
assert_eq!(heading_letter("Äpfel", Some("de")).as_deref(), Some("A"));
|
||||
assert_eq!(heading_letter("Äpple", Some("sv")).as_deref(), Some("Ä"));
|
||||
assert_eq!(heading_letter("", Some("sv")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
|
||||
for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
|
||||
let _ = sort_key(s, l);
|
||||
let _ = heading_letter(s, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,13 @@ impl Fragment {
|
|||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.plain_text(crate::StoryRef::Body)
|
||||
}
|
||||
/// 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 {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.shown_text(crate::StoryRef::Body)
|
||||
}
|
||||
pub fn from_text(s: &str) -> Fragment {
|
||||
let lines: Vec<&str> = s.split('\n').collect();
|
||||
Fragment {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod bidi;
|
|||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
pub mod collate;
|
||||
pub mod connector;
|
||||
pub mod control;
|
||||
pub mod edit;
|
||||
|
|
@ -24,6 +25,7 @@ pub mod fields;
|
|||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod link;
|
||||
pub mod locale_number;
|
||||
pub mod math;
|
||||
pub mod math_edit;
|
||||
pub mod math_latex;
|
||||
|
|
@ -36,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;
|
||||
|
|
@ -279,12 +282,28 @@ pub struct TextBody {
|
|||
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
|
||||
/// 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.
|
||||
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.
|
||||
pub auto_fit: bool,
|
||||
}
|
||||
|
||||
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
|
||||
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
|
||||
/// Widest text box margin kept, EMU (22", the largest page side).
|
||||
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
|
||||
|
||||
impl TextBody {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == TextBody::default()
|
||||
}
|
||||
|
||||
/// The internal margins (left, top, right, bottom) in points.
|
||||
pub fn insets_pt(&self) -> [f32; 4] {
|
||||
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most boxes one chain of linked text boxes runs through (hostile files).
|
||||
|
|
@ -783,9 +802,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();
|
||||
|
|
@ -863,14 +892,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
|
||||
|
|
@ -1258,14 +1295,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) => {
|
||||
|
|
@ -1273,7 +1310,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"));
|
||||
|
|
@ -1284,5 +1321,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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
//! Numbers written the way a language writes them: `1 250,50` in Polish, `1.250,50` in German,
|
||||
//! `1,250.50` in English. Used by table sort and table formulas.
|
||||
|
||||
use crate::collate::language;
|
||||
|
||||
/// The decimal separator of language `lang` (a BCP 47 tag; `None` means English).
|
||||
pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
|
||||
// Regions that write a decimal point where the language usually writes a comma.
|
||||
const POINT_REGIONS: &[&str] =
|
||||
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"];
|
||||
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
|
||||
return '.';
|
||||
}
|
||||
const COMMA: &[&str] = &[
|
||||
"af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it",
|
||||
"ka", "kk", "ky", "lb", "lt", "lv", "mk", "mn", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk",
|
||||
"uz", "vi",
|
||||
];
|
||||
if COMMA.contains(&language(lang).as_str()) { ',' } else { '.' }
|
||||
}
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
{
|
||||
s = head;
|
||||
}
|
||||
if let Some((word, tail)) = s.split_once(SPACES)
|
||||
&& word.chars().count() == 3
|
||||
&& word.chars().all(|c| c.is_ascii_uppercase())
|
||||
{
|
||||
s = tail;
|
||||
}
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Digits of an integer part with group separators `groups`: the first group has 1–3 digits,
|
||||
/// every later one exactly 3.
|
||||
fn grouped_digits(int: &str, groups: &[char]) -> Option<String> {
|
||||
if int.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(int.to_string());
|
||||
}
|
||||
let parts: Vec<&str> = int.split(groups).collect();
|
||||
let (first, rest) = parts.split_first()?;
|
||||
// A first group of `0` is never grouping (`0.125` is a fraction, not 125).
|
||||
let ok = (1..=3).contains(&first.len())
|
||||
&& !first.starts_with('0')
|
||||
&& first.chars().all(|c| c.is_ascii_digit())
|
||||
&& rest.iter().all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit()));
|
||||
ok.then(|| parts.concat())
|
||||
}
|
||||
|
||||
/// Parse `s` as a number written in language `lang`, or `None` when it isn't one. The decimal
|
||||
/// separator is the language's; group separators are spaces (and `.` or `'` where the decimal
|
||||
/// separator is a comma, `,` or `'` where it is a point) between groups of three digits. A
|
||||
/// currency sign or short currency word is ignored, and so is a trailing `%` (`12 %` is 12, as
|
||||
/// table sort reads it). In comma languages a lone `.` that cannot be a group separator (`1.5`)
|
||||
/// is read as a decimal point. A trailing separator with no digits after it (`1.`, an ordinal
|
||||
/// in a numbered column) is the whole number. A minus may be `-`, `−` or an en dash.
|
||||
pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
|
||||
let dec = decimal_separator(lang);
|
||||
let s = s.trim();
|
||||
let s = s.strip_suffix('%').map_or(s, str::trim_end);
|
||||
let mut body = strip_currency(s);
|
||||
let mut neg = false;
|
||||
if let Some(rest) = body.strip_prefix(['-', '\u{2212}', '\u{2013}']) {
|
||||
neg = true;
|
||||
body = strip_currency(rest);
|
||||
} else if let Some(rest) = body.strip_prefix('+') {
|
||||
body = strip_currency(rest);
|
||||
}
|
||||
if body.is_empty() || body.len() > 400 {
|
||||
return None;
|
||||
}
|
||||
let mut groups: Vec<char> = SPACES.to_vec();
|
||||
groups.extend(['\'', '\u{2019}']);
|
||||
groups.push(if dec == ',' { '.' } else { ',' });
|
||||
let (int, frac) = match body.split_once(dec) {
|
||||
Some((i, f)) => (i, Some(f)),
|
||||
None => (body, None),
|
||||
};
|
||||
let int_digits = match grouped_digits(int, &groups) {
|
||||
Some(d) => d,
|
||||
None if dec == ',' && frac.is_none() && int.matches('.').count() == 1 => {
|
||||
return parse_number(&format!("{}{}", if neg { "-" } else { "" }, int.replace('.', ",")), lang);
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
let frac_digits = match frac {
|
||||
Some(f) if f.chars().all(|c| c.is_ascii_digit()) => f,
|
||||
Some(_) => return None,
|
||||
None => "",
|
||||
};
|
||||
if int_digits.is_empty() && frac_digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// Write `v` the way language `lang` writes a formula result: whole numbers without decimals,
|
||||
/// others with two decimals and the language's decimal separator (`5,75` in Polish).
|
||||
pub fn format_number(v: f64, lang: Option<&str>) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
if v.fract() == 0.0 && v.abs() < 1e15 {
|
||||
return format!("{}", v as i64);
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polish_numbers() {
|
||||
let pl = Some("pl-PL");
|
||||
assert_eq!(parse_number("1,5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("3,50", pl), Some(3.5));
|
||||
assert_eq!(parse_number("1 250,00", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1\u{a0}250,00 zł", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1.250,00", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
assert_eq!(parse_number("1.", pl), Some(1.0), "a numbered column: 1.");
|
||||
assert_eq!(parse_number("10.", pl), Some(10.0));
|
||||
assert_eq!(parse_number("0.125", pl), Some(0.125), "a leading 0 is never a group");
|
||||
assert_eq!(parse_number("0,125", pl), Some(0.125));
|
||||
assert_eq!(parse_number("12 %", pl), Some(12.0));
|
||||
assert_eq!(parse_number("12%", pl), Some(12.0));
|
||||
assert_eq!(parse_number("\u{2013}2,5", pl), Some(-2.5));
|
||||
assert_eq!(parse_number("%", pl), None);
|
||||
assert_eq!(parse_number(".", pl), None);
|
||||
assert_eq!(format_number(5.75, pl), "5,75");
|
||||
assert_eq!(format_number(1350.0, pl), "1350");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
assert_eq!(parse_number("1,5", en), None);
|
||||
assert_eq!(parse_number("1.", en), Some(1.0));
|
||||
assert_eq!(parse_number("0,125", en), None, "never 125");
|
||||
assert_eq!(format_number(5.75, en), "5.75");
|
||||
assert_eq!(decimal_separator(Some("de-CH")), '.');
|
||||
assert_eq!(decimal_separator(Some("de-DE")), ',');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_never_panic() {
|
||||
for s in ["", "-", "+", ",", ".", "1e400", "inf", "NaN", "--1", "₽", "1 ,", " zł", "\u{2212}", "9".repeat(500).as_str()] {
|
||||
for l in [None, Some("pl"), Some("en"), Some("")] {
|
||||
let _ = parse_number(s, l);
|
||||
}
|
||||
}
|
||||
assert_eq!(format_number(f64::NAN, None), "0");
|
||||
}
|
||||
}
|
||||
|
|
@ -831,6 +831,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 +1064,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 +1441,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());
|
||||
|
|
|
|||
|
|
@ -31,13 +31,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 {
|
||||
|
|
@ -626,6 +655,33 @@ pub struct NumRef {
|
|||
pub level: u8,
|
||||
}
|
||||
|
||||
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
|
||||
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct TextFrame {
|
||||
/// Width; `None` fits the text.
|
||||
pub w: Option<f32>,
|
||||
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
|
||||
pub h: Option<f32>,
|
||||
pub h_exact: bool,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
/// `left`, `center`, `right`, `inside`, `outside`.
|
||||
pub x_align: Option<String>,
|
||||
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
|
||||
pub y_align: Option<String>,
|
||||
/// `text` (the column), `margin` or `page`.
|
||||
pub h_anchor: String,
|
||||
/// `text` (the paragraph), `margin` or `page`.
|
||||
pub v_anchor: String,
|
||||
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
|
||||
pub wrap: String,
|
||||
/// Distance from the text left and right, and above and below.
|
||||
pub h_space: f32,
|
||||
pub v_space: f32,
|
||||
}
|
||||
|
||||
/// Paragraph formatting. `None` = inherit.
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
|
@ -659,6 +715,13 @@ pub struct ParaProps {
|
|||
pub space_before: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after: Option<f32>,
|
||||
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
|
||||
/// document or of a table cell, and collapsing with the space after the paragraph above.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_before_auto: Option<bool>,
|
||||
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub space_after_auto: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub line_spacing: Option<LineSpacing>,
|
||||
/// Ignore space before/after between paragraphs of the same style.
|
||||
|
|
@ -697,6 +760,10 @@ pub struct ParaProps {
|
|||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
|
||||
/// text, which may wrap around it — a footer's page number in a frame, for instance.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<TextFrame>,
|
||||
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
|
||||
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
|
||||
/// Unset = on.
|
||||
|
|
@ -731,9 +798,9 @@ pub struct ParaProps {
|
|||
|
||||
impl ParaProps {
|
||||
pub fn overlay(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, line_spacing,
|
||||
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
|
||||
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
|
||||
snap_to_grid);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
|
@ -1013,6 +1080,10 @@ pub struct CellProps {
|
|||
/// Which way the cell's text runs.
|
||||
pub text_direction: TextDirection,
|
||||
pub no_wrap: bool,
|
||||
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
|
||||
/// empty cells shrinks to its borders.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub hide_mark: bool,
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
|
|
@ -1083,6 +1154,27 @@ 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":"1B1611"}"#, r#"{"Hex":[1,2,3]}"#, "7", "[27,22,17]"] {
|
||||
assert!(read(bad).is_err(), "{bad}");
|
||||
}
|
||||
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)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn props_serde_skip_none() {
|
||||
let p = CharProps { bold: Some(true), ..Default::default() };
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ use crate::props::{
|
|||
};
|
||||
use crate::styles::{StyleKind, StyleSheet};
|
||||
|
||||
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
|
||||
/// and `w:afterAutospacing` at any font size (measured).
|
||||
pub const AUTO_SPACING: f32 = 14.0;
|
||||
|
||||
/// Concrete character formatting.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -32,6 +36,9 @@ pub struct ResolvedChar {
|
|||
pub spacing: f32,
|
||||
pub scale: f32,
|
||||
pub position: f32,
|
||||
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
|
||||
#[serde(default)]
|
||||
pub kern: f32,
|
||||
pub outline: bool,
|
||||
pub shadow: bool,
|
||||
pub emboss: bool,
|
||||
|
|
@ -82,6 +89,7 @@ impl ResolvedChar {
|
|||
spacing: c.spacing.unwrap_or(0.0).clamp(-100.0, 100.0),
|
||||
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
|
||||
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
|
||||
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
|
||||
outline: c.outline.unwrap_or(false),
|
||||
shadow: c.shadow.unwrap_or(false),
|
||||
emboss: c.emboss.unwrap_or(false),
|
||||
|
|
@ -108,6 +116,11 @@ impl ResolvedChar {
|
|||
pub fn complex(&self) -> ResolvedChar {
|
||||
ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() }
|
||||
}
|
||||
/// Whether the text is kerned: Word kerns only a run with Kerning for fonts set (`w:kern`) at
|
||||
/// a size of at least that many points, never by default (measured in Word for Windows).
|
||||
pub fn kerned(&self) -> bool {
|
||||
self.kern > 0.0 && self.size >= self.kern
|
||||
}
|
||||
/// Does a character of this run use the complex-script properties?
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
|
|
@ -138,6 +151,12 @@ pub struct ResolvedPara {
|
|||
pub indent_first: f32,
|
||||
pub space_before: f32,
|
||||
pub space_after: f32,
|
||||
/// The space before or after is automatic (HTML-style, [`AUTO_SPACING`]): see
|
||||
/// `ParaProps::space_before_auto`.
|
||||
#[serde(default)]
|
||||
pub before_auto: bool,
|
||||
#[serde(default)]
|
||||
pub after_auto: bool,
|
||||
pub line_spacing: LineSpacing,
|
||||
pub contextual_spacing: bool,
|
||||
/// Left/right indents are inside/outside: they swap on even pages.
|
||||
|
|
@ -153,6 +172,9 @@ pub struct ResolvedPara {
|
|||
pub borders: Option<Borders>,
|
||||
pub bidi: bool,
|
||||
pub drop_cap: u8,
|
||||
/// A text frame the paragraph sits in (see `ParaProps::frame`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub frame: Option<crate::props::TextFrame>,
|
||||
pub suppress_hyphens: bool,
|
||||
pub suppress_line_numbers: bool,
|
||||
/// Asian typography (see `ParaProps`): East Asian line-breaking rules.
|
||||
|
|
@ -206,8 +228,10 @@ impl ResolvedPara {
|
|||
indent_left: clampi(p.indent_left),
|
||||
indent_right: clampi(p.indent_right),
|
||||
indent_first: clampi(p.indent_first),
|
||||
space_before: p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0),
|
||||
space_after: p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0),
|
||||
space_before: if p.space_before_auto == Some(true) { AUTO_SPACING } else { p.space_before.unwrap_or(0.0).clamp(0.0, 1584.0) },
|
||||
space_after: if p.space_after_auto == Some(true) { AUTO_SPACING } else { p.space_after.unwrap_or(0.0).clamp(0.0, 1584.0) },
|
||||
before_auto: p.space_before_auto == Some(true),
|
||||
after_auto: p.space_after_auto == Some(true),
|
||||
line_spacing: match p.line_spacing.unwrap_or_default() {
|
||||
LineSpacing::Multiple(m) => LineSpacing::Multiple(if m.is_finite() { m.clamp(0.06, 132.0) } else { 1.0 }),
|
||||
LineSpacing::AtLeast(v) => LineSpacing::AtLeast(if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }),
|
||||
|
|
@ -226,6 +250,7 @@ impl ResolvedPara {
|
|||
borders: p.borders.filter(Borders::any_visible),
|
||||
bidi: p.bidi.unwrap_or(false),
|
||||
drop_cap: p.drop_cap.unwrap_or(0).min(10),
|
||||
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
|
||||
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
|
||||
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
|
||||
kinsoku: p.kinsoku.unwrap_or(true),
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ impl StyleSheet {
|
|||
/// The built-in style set used for new documents.
|
||||
pub fn builtin() -> Self {
|
||||
let mut s = StyleSheet {
|
||||
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(0.0), lang: Some("en-US".into()), ..chr() },
|
||||
default_chr: CharProps { font: Some(BODY_FONT.into()), size: Some(12.0), kern: Some(1.0), lang: Some("en-US".into()), ..chr() },
|
||||
default_para: ParaProps {
|
||||
space_after: Some(8.0),
|
||||
space_before: Some(0.0),
|
||||
|
|
@ -444,6 +444,19 @@ impl StyleSheet {
|
|||
out.reverse();
|
||||
out
|
||||
}
|
||||
/// Which indents (left, first line) of a paragraph that gets its list from style `id` the style
|
||||
/// chain sets over the list level's own. Word applies the level's indents on top of the most
|
||||
/// derived style that gives a list, so that style and the ones based on it win over the level,
|
||||
/// and the styles it is based on don't (Heading 3 with its own left indent on Heading 2's
|
||||
/// list). `(false, false)` when no style in the chain gives a list.
|
||||
pub fn indents_over_list(&self, id: &str) -> (bool, bool) {
|
||||
let chain = self.chain(id);
|
||||
let Some(k) = chain.iter().rposition(|s| s.para.numbering.is_some()) else { return (false, false) };
|
||||
let set = |v: Option<f32>, chars: Option<f32>| v.is_some() || chars.is_some_and(|c| c != 0.0);
|
||||
chain.get(k..).unwrap_or_default().iter().fold((false, false), |(l, f), s| {
|
||||
(l || set(s.para.indent_left, s.para.indent_left_chars), f || set(s.para.indent_first, s.para.indent_first_chars))
|
||||
})
|
||||
}
|
||||
/// Table style `id` with its based-on chain merged, base styles first (`None` when there is no
|
||||
/// such table style). Styles of other kinds in the chain are skipped.
|
||||
pub fn table_style(&self, id: &str) -> Option<TableStyleProps> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Sprms → model properties, and style-chain resolution.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
|
||||
|
||||
use crate::sprm::{self, Prl, Toggle};
|
||||
|
|
@ -187,11 +187,63 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
|
|||
}
|
||||
}
|
||||
|
||||
/// Custom tab stops from `sprmPChgTabsPapx` / `sprmPChgTabs` ([MS-DOC] PChgTabsPapxOperand,
|
||||
/// PChgTabsOperand): the stops removed become `Clear` stops (they hide inherited ones) and the
|
||||
/// stops added carry their alignment and leader (TBD: jc in bits 0–2, tlc in bits 3–5).
|
||||
fn change_tabs(p: &mut ParaProps, prl: &Prl) {
|
||||
let op = prl.operand;
|
||||
let i16_at = |i: usize| op.get(i..i + 2).map(|b| f32::from(i16::from_le_bytes([b[0], b[1]])) / 20.0);
|
||||
// Both operands start with cb; sprmPChgTabs's removals also carry a "close" distance each.
|
||||
let close = prl.op == sprm::P_CHG_TABS;
|
||||
let mut at = 1;
|
||||
let Some(&del) = op.get(at) else { return };
|
||||
let del = usize::from(del.min(64));
|
||||
at += 1;
|
||||
let mut removed = Vec::with_capacity(del);
|
||||
for k in 0..del {
|
||||
if let Some(x) = i16_at(at + 2 * k) {
|
||||
removed.push(x);
|
||||
}
|
||||
}
|
||||
at += 2 * del * if close { 2 } else { 1 };
|
||||
let Some(&add) = op.get(at) else { return };
|
||||
let add = usize::from(add.min(64));
|
||||
at += 1;
|
||||
let mut tabs = p.tabs.take().unwrap_or_default();
|
||||
// Removed within 25 twips (1.25 pt), as the format says.
|
||||
tabs.retain(|t| !removed.iter().any(|x| (t.pos - x).abs() <= 1.25));
|
||||
for x in removed {
|
||||
tabs.push(TabStop { pos: x, align: TabAlign::Clear, leader: TabLeader::None });
|
||||
}
|
||||
for k in 0..add {
|
||||
let (Some(pos), Some(&tbd)) = (i16_at(at + 2 * k), op.get(at + 2 * add + k)) else { break };
|
||||
let align = match tbd & 7 {
|
||||
1 => TabAlign::Center,
|
||||
2 => TabAlign::Right,
|
||||
3 => TabAlign::Decimal,
|
||||
4 => TabAlign::Bar,
|
||||
_ => TabAlign::Left,
|
||||
};
|
||||
let leader = match (tbd >> 3) & 7 {
|
||||
1 => TabLeader::Dot,
|
||||
2 => TabLeader::Hyphen,
|
||||
3 | 4 => TabLeader::Underscore,
|
||||
5 => TabLeader::MiddleDot,
|
||||
_ => TabLeader::None,
|
||||
};
|
||||
tabs.retain(|t| (t.pos - pos).abs() > 0.05);
|
||||
tabs.push(TabStop { pos, align, leader });
|
||||
}
|
||||
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
|
||||
/// Apply a paragraph-property sprm.
|
||||
pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
|
||||
let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0);
|
||||
let s16 = |prl: &Prl| -> Option<i16> { u16_of(prl).map(|v| v as i16) };
|
||||
match prl.op {
|
||||
sprm::P_CHG_TABS | sprm::P_CHG_TABS_PAPX => change_tabs(p, prl),
|
||||
P_JC | P_JC80 => {
|
||||
p.align = match prl.operand.first().copied().unwrap_or(0) {
|
||||
1 => Some(Align::Center),
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
|||
notes: &[],
|
||||
marks: &[],
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
|
|
@ -215,7 +216,9 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
|||
// Sorted so the main walk finds each reference by binary search.
|
||||
note_links.sort_by_key(|(cp, _, _)| *cp);
|
||||
let marks = notes::parse_bookmarks(&table, &fib);
|
||||
let ctx = WalkCtx { notes: ¬e_links, marks: &marks, ..ctx };
|
||||
let mut sect_ends: Vec<u32> = sects.iter().map(|s| s.end_cp).collect();
|
||||
sect_ends.sort_unstable();
|
||||
let ctx = WalkCtx { notes: ¬e_links, marks: &marks, sect_ends: §_ends, ..ctx };
|
||||
|
||||
// Sections: each section's properties attach to the paragraph that ends it (its story of
|
||||
// header/footer parts included); the section reaching the end of the text is the final one.
|
||||
|
|
@ -277,6 +280,9 @@ struct WalkCtx<'a> {
|
|||
marks: &'a [(u32, notes::Mark)],
|
||||
/// Floating shapes and pictures by anchor CP (absolute), sorted.
|
||||
drawn: &'a [(u32, InlineObject)],
|
||||
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
|
||||
/// each ends the section's last paragraph, it isn't a page break inside it.
|
||||
sect_ends: &'a [u32],
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
|
|
@ -350,8 +356,10 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
|||
continue;
|
||||
}
|
||||
};
|
||||
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
|
||||
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
|
||||
match c {
|
||||
'\r' | '\u{7}' => {
|
||||
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
|
||||
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
|
||||
// querying at the mark's own FC finds that paragraph's properties.
|
||||
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! Sprm decoding ([MS-DOC] §2.6). A Prl is a 2-byte opcode followed by an operand whose
|
||||
//! size comes from the opcode's `spra` field (with two variable-length exceptions).
|
||||
|
||||
/// `sprmPChgTabs`: custom tab stops ([MS-DOC] §2.6.29 — handled in a later pass).
|
||||
const P_CHG_TABS: u16 = 0xC615;
|
||||
/// `sprmPChgTabs`: custom tab stops added and removed ([MS-DOC] §2.6.3, PChgTabsOperand).
|
||||
pub(crate) const P_CHG_TABS: u16 = 0xC615;
|
||||
/// `sprmPChgTabsPapx`: the same for paragraph and style formatting (PChgTabsPapxOperand).
|
||||
pub(crate) const P_CHG_TABS_PAPX: u16 = 0xC60D;
|
||||
/// `sprmTDefTable`: table cell borders and widths.
|
||||
const T_DEF_TABLE: u16 = 0xD608;
|
||||
|
||||
|
|
@ -32,7 +34,16 @@ pub(crate) fn iter(grpprl: &[u8]) -> impl Iterator<Item = Prl<'_>> {
|
|||
let after = &rest[2..];
|
||||
// Operand size per spra, with the exceptions whose size byte lies.
|
||||
let len = match (op >> 13, op) {
|
||||
(_, P_CHG_TABS) => after.get(..2).map(|b| 2 + u16::from_le_bytes([b[0], b[1]]) as usize),
|
||||
// PChgTabsOperand.cb is one byte, the size after it; 255 means "work it out" from the
|
||||
// two counts: cb, cTabs, 4 bytes per removed stop, cTabs, 3 bytes per added stop.
|
||||
(_, P_CHG_TABS) => match after.first().copied() {
|
||||
Some(255) => after.get(1).and_then(|&del| {
|
||||
let add = after.get(2 + 4 * del as usize).copied()?;
|
||||
Some(1 + 1 + 4 * del as usize + 1 + 3 * add as usize)
|
||||
}),
|
||||
Some(cb) => Some(1 + cb as usize),
|
||||
None => None,
|
||||
},
|
||||
// TDefTableOperand.cb counts the bytes after cb, incremented by 1, so the
|
||||
// operand is one byte longer than cb.
|
||||
(_, T_DEF_TABLE) => after.get(..2).map(|b| 1 + u16::from_le_bytes([b[0], b[1]]) as usize),
|
||||
|
|
|
|||
|
|
@ -1803,3 +1803,106 @@ fn alternate_font_name_keeps_later_entries() {
|
|||
assert_eq!(fonts, [Some("Arial".to_string()), Some("Courier New".to_string())], "alt {alt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// PChgTabsPapxOperand bytes: cb, removed stops, added stops with their TBDs.
|
||||
fn tabs_papx(del: &[i16], add: &[(i16, u8)]) -> Vec<u8> {
|
||||
let mut v = vec![0u8, del.len() as u8];
|
||||
for d in del {
|
||||
v.extend_from_slice(&d.to_le_bytes());
|
||||
}
|
||||
v.push(add.len() as u8);
|
||||
for (x, _) in add {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
v.extend(add.iter().map(|(_, tbd)| *tbd));
|
||||
v[0] = (v.len() - 1) as u8;
|
||||
v
|
||||
}
|
||||
|
||||
/// Custom tab stops ([MS-DOC] sprmPChgTabsPapx, sprmPChgTabs) were skipped, so tab-aligned text
|
||||
/// (centred titles, right-aligned dates, dot leaders in forms) lost its alignment. Stops added by
|
||||
/// a style and by the paragraph, stops it removes, and the TBD's alignment and leader are read.
|
||||
#[test]
|
||||
fn tab_stops_read_from_styles_and_paragraphs() {
|
||||
use wordcraft_doc::props::{TabAlign, TabLeader};
|
||||
// Normal: centre at 3.25" (4680 tw), right at 6.5" (9360 tw) with a dot leader (tlc 1).
|
||||
let normal = grpprl(&[(0xC60D, &tabs_papx(&[], &[(4680, 1), (9360, 2 | 1 << 3)]))]);
|
||||
// The paragraph drops the centre stop and adds a left stop at 0.5" and a bar at 1".
|
||||
let direct = grpprl(&[(0xC60D, &tabs_papx(&[4680], &[(720, 0), (1440, 4)]))]);
|
||||
// The second paragraph uses the older sprmPChgTabs: removals carry a "close" distance.
|
||||
let mut old = vec![0u8, 1];
|
||||
old.extend_from_slice(&9360i16.to_le_bytes());
|
||||
old.extend_from_slice(&25i16.to_le_bytes());
|
||||
old.push(1);
|
||||
old.extend_from_slice(&2880i16.to_le_bytes());
|
||||
old.push(3 | 5 << 3); // decimal, middle dot
|
||||
old[0] = (old.len() - 1) as u8;
|
||||
let spec = RichSpec {
|
||||
text: "\tA\tB\r\tC\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", normal, Vec::new())],
|
||||
fonts: vec!["Calibri"],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, direct), (5, 0, grpprl(&[(0xC615, &old)]))],
|
||||
};
|
||||
let doc = read(&rich_doc(&spec)).expect("opens");
|
||||
let tabs = |i: usize| {
|
||||
let p = doc.body.get(i).and_then(|b| b.as_para()).expect("para");
|
||||
doc.styles.resolve_para(&p.props).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
tabs(0),
|
||||
[(36.0, TabAlign::Left, TabLeader::None), (72.0, TabAlign::Bar, TabLeader::None), (468.0, TabAlign::Right, TabLeader::Dot)],
|
||||
"the style's stops, less the removed one, plus the paragraph's"
|
||||
);
|
||||
assert_eq!(tabs(1), [(144.0, TabAlign::Decimal, TabLeader::MiddleDot), (234.0, TabAlign::Center, TabLeader::None)], "sprmPChgTabs");
|
||||
assert_eq!(doc.styles.styles.iter().find(|s| s.name == "Normal").and_then(|s| s.para.tabs.as_ref()).map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// Hostile tab operands (counts past the end, more than 64 stops, cb 255) never panic and
|
||||
/// never swallow the sprms after them.
|
||||
#[test]
|
||||
fn hostile_tab_operands_are_safe() {
|
||||
for operand in [vec![2u8, 200, 9], vec![0], vec![255, 1, 0x10, 0x00], vec![255, 3], vec![5, 0, 70, 0, 1, 2]] {
|
||||
let papx = grpprl(&[(0xC615, &operand), (0x2403, &[1])]);
|
||||
let spec = RichSpec {
|
||||
text: "x\r",
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Calibri"],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, papx)],
|
||||
};
|
||||
let _ = read(&rich_doc(&spec));
|
||||
}
|
||||
let mut iter = crate::sprm::iter(&[0x15, 0xC6, 3, 0, 0, 0, 0x03, 0x24, 1]);
|
||||
assert_eq!(iter.next().map(|p| (p.op, p.operand.len())), Some((0xC615, 4)), "cb is one byte: the operand is cb + 1 bytes");
|
||||
assert_eq!(iter.next().map(|p| p.op), Some(0x2403), "the next sprm is still read");
|
||||
}
|
||||
|
||||
/// A section mark (0x0C) at a section's end is the section's last paragraph mark, not a page
|
||||
/// break inside a paragraph: the section's properties (here continuous) land on that paragraph.
|
||||
/// Before, every section of such a file was lost and each section mark became a page break.
|
||||
#[test]
|
||||
fn section_marks_end_paragraphs() {
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
let spec = StructSpec {
|
||||
text: "first\u{C}second\r",
|
||||
header_stories: vec![],
|
||||
sections: vec![(6, grpprl(&[(0x3009, &[0x00])])), (13, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(body_texts(&doc), ["first", "second"]);
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
assert_eq!(p0.section.as_ref().map(|s| s.start), Some(SectionStart::Continuous), "the section ends at its mark");
|
||||
// A page break inside a section stays a page break.
|
||||
let spec = StructSpec { text: "a\u{C}b\r", sections: vec![(4, Vec::new())], papx_entries: vec![(0, 0, Vec::new())], ..spec };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,8 +233,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
|||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
toc_field_to_heading(&mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
toc_field_to_heading(&mut r.doc.body);
|
||||
if let Some(s) = body.child("w:sectPr") {
|
||||
r.doc.last_section = r.read_section(s, &rels);
|
||||
}
|
||||
|
|
@ -389,6 +390,23 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Word draws a table of contents' entry links without the Hyperlink character style (no blue,
|
||||
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
|
||||
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
|
||||
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
|
||||
/// those runs so they look the same here; the links stay.
|
||||
fn unstyle_toc_links(doc: &mut Document) {
|
||||
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
|
||||
if is_toc(&f.instr) {
|
||||
let _ = doc.format_range(&f.start, &f.end, &|c| {
|
||||
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") {
|
||||
c.style = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize only a complete TOC range that the engine's heading/entry representation can
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
fn toc_field_to_heading(body: &mut Blocks) {
|
||||
|
|
@ -774,6 +792,25 @@ impl Reader<'_> {
|
|||
}
|
||||
sheet.styles.push(st);
|
||||
}
|
||||
// Styles that give only a list level take the list from the style they are based on
|
||||
// (Heading 3: level 3 of Heading 2's outline list).
|
||||
let fixed: Vec<Option<wordcraft_doc::props::NumRef>> = sheet
|
||||
.styles
|
||||
.iter()
|
||||
.map(|s| match s.para.numbering {
|
||||
Some(n) if n.num == props::LEVEL_ONLY => {
|
||||
let base = s.based_on.as_deref().and_then(|b| props::chain_list(&sheet, b));
|
||||
base.map(|num| wordcraft_doc::props::NumRef { num, level: n.level })
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
for (s, n) in sheet.styles.iter_mut().zip(fixed) {
|
||||
s.para.numbering = n;
|
||||
}
|
||||
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
|
||||
sheet.default_para.numbering = None;
|
||||
}
|
||||
self.doc.styles = sheet;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,28 @@ use wordcraft_doc::section::{
|
|||
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, measure, on_off, tw, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
|
||||
let len = |name: &str| tw(k, name).filter(|v| v.is_finite()).map(|v| v.clamp(-1584.0, 1584.0));
|
||||
let word = |name: &str, allowed: &[&str], default: &str| k.attr(name).filter(|v| allowed.contains(v)).unwrap_or(default).to_string();
|
||||
let align = |name: &str, allowed: &[&str]| k.attr(name).filter(|v| allowed.contains(v)).map(str::to_string);
|
||||
wordcraft_doc::props::TextFrame {
|
||||
w: len("w:w").filter(|v| *v > 0.0),
|
||||
h: len("w:h").map(f32::abs).filter(|v| *v > 0.0),
|
||||
h_exact: k.attr("w:hRule") == Some("exact"),
|
||||
x: len("w:x").unwrap_or(0.0),
|
||||
y: len("w:y").unwrap_or(0.0),
|
||||
x_align: align("w:xAlign", &["left", "center", "right", "inside", "outside"]),
|
||||
y_align: align("w:yAlign", &["top", "center", "bottom", "inside", "outside"]),
|
||||
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
|
||||
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
|
||||
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
|
||||
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
|
||||
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
|
||||
}
|
||||
}
|
||||
use crate::xml::El;
|
||||
|
||||
/// Context for property reading: theme fonts and colours, and style id aliases.
|
||||
|
|
@ -33,6 +55,27 @@ pub struct PropCtx {
|
|||
pub theme: Vec<Rgb>,
|
||||
}
|
||||
|
||||
/// A `w:numPr` that gives a level (`w:ilvl`) and no list (`w:numId`): the list is the one the
|
||||
/// paragraph's style chain uses (ECMA-376 §17.3.1.19). A placeholder while reading, never left in
|
||||
/// a document (`resolve_list_levels`).
|
||||
pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
|
||||
|
||||
/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain
|
||||
/// (its own included) that names a list. `None` when no style in the chain has one.
|
||||
pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> {
|
||||
sheet.chain(style).iter().rev().filter_map(|s| s.para.numbering).map(|n| n.num).find(|n| *n != LEVEL_ONLY)
|
||||
}
|
||||
|
||||
/// Resolve level-only numbering in `p` against its style chain (`style` when it has none).
|
||||
pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &mut ParaProps, style: &str) {
|
||||
if let Some(n) = p.numbering.as_mut().filter(|n| n.num == LEVEL_ONLY) {
|
||||
match chain_list(sheet, p.style.as_deref().unwrap_or(style)) {
|
||||
Some(num) => n.num = num,
|
||||
None => p.numbering = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
|
|
@ -194,6 +237,7 @@ impl PropCtx {
|
|||
|
||||
/// `w:pPr` (without `w:sectPr`; `w:rPr` is the paragraph mark, returned separately).
|
||||
pub fn ppr(&self, e: &El) -> (ParaProps, CharProps) {
|
||||
// (`w:numPr` with a level and no list sets `LEVEL_ONLY`, resolved by the caller.)
|
||||
let mut p = ParaProps::default();
|
||||
let mut mark = CharProps::default();
|
||||
for k in e.els() {
|
||||
|
|
@ -218,13 +262,21 @@ impl PropCtx {
|
|||
"w:framePr" => {
|
||||
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
|
||||
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
|
||||
} else {
|
||||
p.frame = Some(text_frame(k));
|
||||
}
|
||||
}
|
||||
"w:numPr" => {
|
||||
let num = k.child_val("w:numId").and_then(u32_of);
|
||||
let level = k.child_val("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8;
|
||||
if let Some(num) = num {
|
||||
p.numbering = Some(NumRef { num, level });
|
||||
let ilvl = k.child_val("w:ilvl").and_then(u32_of);
|
||||
let level = ilvl.unwrap_or(0).min(8) as u8;
|
||||
match (num, ilvl) {
|
||||
(Some(num), _) => p.numbering = Some(NumRef { num, level }),
|
||||
// A level without a list: the list comes from the based-on style, as in
|
||||
// a Heading 3 that only says "level 3" of Heading 2's list. Resolved once
|
||||
// the styles are read (`resolve_list_levels`).
|
||||
(None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
"w:pBdr" => p.borders = Some(borders(k)),
|
||||
|
|
@ -240,6 +292,14 @@ impl PropCtx {
|
|||
if let Some(v) = tw(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
let auto = |name| k.attr(name).map(|v| matches!(v.trim(), "1" | "true" | "on"));
|
||||
if let Some(a) = auto("w:beforeAutospacing") {
|
||||
p.space_before_auto = Some(a);
|
||||
}
|
||||
if let Some(a) = auto("w:afterAutospacing") {
|
||||
p.space_after_auto = Some(a);
|
||||
}
|
||||
if let Some(line) = k.attr("w:line") {
|
||||
// Word reads the rule case-insensitively (`atleast` is seen in the wild).
|
||||
let rule = k.attr("w:lineRule").unwrap_or("auto").to_ascii_lowercase();
|
||||
|
|
@ -478,6 +538,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
|||
"w:tcBorders" => c.borders = Some(borders(k)),
|
||||
"w:shd" => c.shading = pc.shd_fill(k),
|
||||
"w:noWrap" => c.no_wrap = on_off(k),
|
||||
"w:hideMark" => c.hide_mark = on_off(k),
|
||||
"w:tcMar" => c.margins = Some(margins(k)),
|
||||
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
|
||||
"w:vAlign" => {
|
||||
|
|
|
|||
|
|
@ -334,7 +334,8 @@ impl Reader<'_> {
|
|||
pb.push_obj(o, &CharProps::default());
|
||||
}
|
||||
if let Some(ppr) = e.child("w:pPr") {
|
||||
let (props, mark) = self.pc.ppr(ppr);
|
||||
let (mut props, mark) = self.pc.ppr(ppr);
|
||||
super::props::resolve_level_only(&self.doc.styles, &mut props, "Normal");
|
||||
para.props = props;
|
||||
para.mark = mark;
|
||||
// A tracked paragraph mark (ECMA-376 §17.13.5.16 / §17.13.5.15): `w:ins` / `w:del`
|
||||
|
|
@ -356,7 +357,14 @@ impl Reader<'_> {
|
|||
// Tracked formatting changes (§17.13.5.29, §17.13.5.19): the paragraph properties
|
||||
// before the change, and a change of the list numbering.
|
||||
if let Some(ch) = ppr.child("w:pPrChange") {
|
||||
let old = ch.child("w:pPr").map(|o| self.pc.ppr(o).0.formatting()).unwrap_or_default();
|
||||
let old = ch
|
||||
.child("w:pPr")
|
||||
.map(|o| {
|
||||
let mut old = self.pc.ppr(o).0;
|
||||
super::props::resolve_level_only(&self.doc.styles, &mut old, "Normal");
|
||||
old.formatting()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
para.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
if para.props.numbering.is_some()
|
||||
|
|
@ -936,17 +944,14 @@ impl Reader<'_> {
|
|||
} else {
|
||||
let wsp = gd.find("wps:wsp")?;
|
||||
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
|
||||
// A freeform: its custom geometry, and whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { kind, freeform, .. } = &mut obj
|
||||
&& let Some(sppr) = wsp.child("wps:spPr")
|
||||
&& let Some(mut f) = super::freeform::cust_geom(sppr, w, h)
|
||||
// A freeform: whether it's ink WordCraft wrote.
|
||||
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
|
||||
&& let Some(name) = c.child("wp:docPr").and_then(|p| p.attr("name"))
|
||||
&& let Some(tool) = super::freeform::ink_tool(name)
|
||||
{
|
||||
f.alpha = super::freeform::line_alpha(sppr.child("a:ln"));
|
||||
let name = c.child("wp:docPr").and_then(|p| p.attr("name"));
|
||||
f.ink = name.and_then(super::freeform::ink_tool);
|
||||
f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order);
|
||||
*kind = ShapeKind::Freeform;
|
||||
*freeform = Some(Arc::new(f));
|
||||
let f = Arc::make_mut(f);
|
||||
f.ink = Some(tool);
|
||||
f.order = super::freeform::ink_order(name);
|
||||
}
|
||||
obj
|
||||
};
|
||||
|
|
@ -1217,6 +1222,15 @@ impl Reader<'_> {
|
|||
if let Some(a) = bp.attr("anchor") {
|
||||
part.body.anchor = wordcraft_doc::props::valign_from_anchor(a);
|
||||
}
|
||||
// Internal margins, each defaulting to Word's (#743).
|
||||
let mut inset = wordcraft_doc::TEXT_BOX_INSET;
|
||||
for (slot, n) in inset.iter_mut().zip(["lIns", "tIns", "rIns", "bIns"]) {
|
||||
if let Some(v) = bp.attr(n).and_then(int) {
|
||||
*slot = u32::try_from(v.clamp(0, i64::from(wordcraft_doc::MAX_TEXT_BOX_INSET))).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
|
||||
part.body.auto_fit = bp.child("a:spAutoFit").is_some();
|
||||
}
|
||||
let mut extra = ShapeExtra::default();
|
||||
// Arrowheads: any end type but `none` (ECMA-376 §20.1.10.33) is drawn as a triangle.
|
||||
|
|
@ -1236,7 +1250,13 @@ impl Reader<'_> {
|
|||
self.glued.extend(extra.start.iter().chain(extra.end.iter()).map(|e| e.id));
|
||||
}
|
||||
extra.warp = wsp.child("wps:bodyPr").and_then(|b| b.child("a:prstTxWarp")).and_then(text_warp);
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects, extra }
|
||||
// A freeform: its custom geometry (#743: in a group too).
|
||||
let freeform = sppr.and_then(|s| super::freeform::cust_geom(s, w, h)).map(|mut f| {
|
||||
f.alpha = super::freeform::line_alpha(ln);
|
||||
kind = ShapeKind::Freeform;
|
||||
Arc::new(f)
|
||||
});
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform, effects, extra }
|
||||
}
|
||||
|
||||
/// Keep drawing id `id` on a shape read from a file (see [`Reader::glued`]).
|
||||
|
|
|
|||
|
|
@ -209,6 +209,35 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
|||
"w:framePr",
|
||||
&[("w:dropCap", "drop"), ("w:lines", &n(lines.min(10) as i64)), ("w:wrap", "around"), ("w:vAnchor", "text"), ("w:hAnchor", "text")],
|
||||
);
|
||||
} else if let Some(f) = &p.frame {
|
||||
let (fw, fh, fx, fy) = (f.w.map(twips), f.h.map(twips), twips(f.x), twips(f.y));
|
||||
let (hs, vs) = (twips(f.h_space), twips(f.v_space));
|
||||
let mut a: Vec<(&str, &str)> = Vec::new();
|
||||
if let Some(v) = &fw {
|
||||
a.push(("w:w", v));
|
||||
}
|
||||
if let Some(v) = &fh {
|
||||
a.push(("w:h", v));
|
||||
a.push(("w:hRule", if f.h_exact { "exact" } else { "atLeast" }));
|
||||
}
|
||||
if f.h_space > 0.0 {
|
||||
a.push(("w:hSpace", &hs));
|
||||
}
|
||||
if f.v_space > 0.0 {
|
||||
a.push(("w:vSpace", &vs));
|
||||
}
|
||||
a.push(("w:wrap", &f.wrap));
|
||||
a.push(("w:vAnchor", &f.v_anchor));
|
||||
a.push(("w:hAnchor", &f.h_anchor));
|
||||
match &f.x_align {
|
||||
Some(x) => a.push(("w:xAlign", x)),
|
||||
None => a.push(("w:x", &fx)),
|
||||
}
|
||||
match &f.y_align {
|
||||
Some(y) => a.push(("w:yAlign", y)),
|
||||
None => a.push(("w:y", &fy)),
|
||||
}
|
||||
w.empty("w:framePr", &a);
|
||||
}
|
||||
toggle(w, "w:widowControl", p.widow_control);
|
||||
if let Some(nr) = p.numbering {
|
||||
|
|
@ -266,7 +295,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
|||
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
|
||||
toggle(w, "w:bidi", p.bidi);
|
||||
toggle(w, "w:snapToGrid", p.snap_to_grid);
|
||||
if p.space_before.is_some() || p.space_after.is_some() || p.line_spacing.is_some() {
|
||||
if p.space_before.is_some()
|
||||
|| p.space_after.is_some()
|
||||
|| p.line_spacing.is_some()
|
||||
|| p.space_before_auto.is_some()
|
||||
|| p.space_after_auto.is_some()
|
||||
{
|
||||
let before = p.space_before.map(|v| twips(v.max(0.0)));
|
||||
let after = p.space_after.map(|v| twips(v.max(0.0)));
|
||||
let line = p.line_spacing.map(|l| match l {
|
||||
|
|
@ -278,9 +312,15 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
|||
if let Some(b) = &before {
|
||||
a.push(("w:before", b));
|
||||
}
|
||||
if let Some(on) = p.space_before_auto {
|
||||
a.push(("w:beforeAutospacing", if on { "1" } else { "0" }));
|
||||
}
|
||||
if let Some(x) = &after {
|
||||
a.push(("w:after", x));
|
||||
}
|
||||
if let Some(on) = p.space_after_auto {
|
||||
a.push(("w:afterAutospacing", if on { "1" } else { "0" }));
|
||||
}
|
||||
if let Some((l, r)) = &line {
|
||||
a.push(("w:line", l));
|
||||
a.push(("w:lineRule", r));
|
||||
|
|
@ -497,6 +537,9 @@ fn tcpr_inner(w: &mut W, c: &CellProps) {
|
|||
VAlign::Center => w.val("w:vAlign", "center"),
|
||||
VAlign::Bottom => w.val("w:vAlign", "bottom"),
|
||||
}
|
||||
if c.hide_mark {
|
||||
w.empty("w:hideMark", &[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `w:tblpPr` and `w:tblOverlap` for a floating table.
|
||||
|
|
|
|||
|
|
@ -1031,13 +1031,25 @@ impl Writer<'_> {
|
|||
if body.anchor != wordcraft_doc::props::VAlign::Top {
|
||||
attrs.push(("anchor", wordcraft_doc::props::anchor_ooxml(body.anchor)));
|
||||
}
|
||||
match extra.warp.as_ref().and_then(|t| t.sanitized()) {
|
||||
Some(warp) => {
|
||||
w.open("wps:bodyPr", &attrs);
|
||||
text_warp(w, &warp);
|
||||
w.close("wps:bodyPr");
|
||||
// Internal margins, when the box has its own.
|
||||
let inset = body.inset.map(|i| i.map(|v| v.min(wordcraft_doc::MAX_TEXT_BOX_INSET).to_string()));
|
||||
if let Some(inset) = &inset {
|
||||
for (n, v) in ["lIns", "tIns", "rIns", "bIns"].into_iter().zip(inset) {
|
||||
attrs.push((n, v));
|
||||
}
|
||||
None => w.empty("wps:bodyPr", &attrs),
|
||||
}
|
||||
let warp = extra.warp.as_ref().and_then(|t| t.sanitized());
|
||||
if warp.is_none() && !body.auto_fit {
|
||||
w.empty("wps:bodyPr", &attrs);
|
||||
} else {
|
||||
w.open("wps:bodyPr", &attrs);
|
||||
if let Some(warp) = &warp {
|
||||
text_warp(w, warp);
|
||||
}
|
||||
if body.auto_fit {
|
||||
w.empty("a:spAutoFit", &[]);
|
||||
}
|
||||
w.close("wps:bodyPr");
|
||||
}
|
||||
w.close("wps:wsp");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,3 +53,30 @@ fn character_indents_read_and_round_trip() {
|
|||
let again = props(&wordcraft_docx::read(&docx(&xml[xml.find("<w:body>").unwrap() + 8..xml.find("</w:body>").unwrap()])).unwrap());
|
||||
assert_eq!(again[..3], p[..3]);
|
||||
}
|
||||
|
||||
/// HTML-style automatic paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`, ECMA-376
|
||||
/// §17.3.1.33), common in text pasted from web pages: read, "0" switching an inherited one off,
|
||||
/// and written back.
|
||||
#[test]
|
||||
fn automatic_spacing_reads_and_round_trips() {
|
||||
let body = r#"<w:p><w:pPr><w:spacing w:before="100" w:beforeAutospacing="1" w:after="100" w:afterAutospacing="1"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:spacing w:before="0" w:beforeAutospacing="0" w:after="100" w:afterAutospacing="true"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:spacing w:after="100"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
let p = props(&d);
|
||||
assert_eq!((p[0].space_before_auto, p[0].space_after_auto, p[0].space_before), (Some(true), Some(true), Some(5.0)));
|
||||
assert_eq!((p[1].space_before_auto, p[1].space_after_auto), (Some(false), Some(true)));
|
||||
assert_eq!((p[2].space_before_auto, p[2].space_after_auto), (None, None));
|
||||
let rp = d.styles.resolve_para(&p[0]);
|
||||
assert_eq!((rp.space_before, rp.space_after, rp.before_auto), (14.0, 14.0, true), "automatic spacing is 14 pt");
|
||||
assert_eq!(d.styles.resolve_para(&p[2]).space_after, 5.0);
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
for attr in [r#"w:beforeAutospacing="1""#, r#"w:afterAutospacing="1""#, r#"w:beforeAutospacing="0""#] {
|
||||
assert!(xml.contains(attr), "{attr} missing");
|
||||
}
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(props(&again)[..3], p[..3]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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#"
|
||||
|
|
@ -1514,6 +1539,76 @@ fn vml_lines_and_rectangles() {
|
|||
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
|
||||
}
|
||||
|
||||
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
|
||||
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
|
||||
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
|
||||
/// and survive saving.
|
||||
#[test]
|
||||
fn cover_page_group_fills_freeforms_and_text_box_margins() {
|
||||
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></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 member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
|
||||
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
|
||||
format!(
|
||||
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
|
||||
off.0, off.1, ext.0, ext.1
|
||||
)
|
||||
};
|
||||
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
|
||||
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
|
||||
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
|
||||
let members = [
|
||||
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
|
||||
member(
|
||||
12,
|
||||
false,
|
||||
(914400, 4572000),
|
||||
(4572000, 914400),
|
||||
band,
|
||||
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
|
||||
"<wps:bodyPr/>",
|
||||
),
|
||||
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
|
||||
]
|
||||
.concat();
|
||||
let group = format!(
|
||||
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><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="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
|
||||
);
|
||||
let subtitle = anchored_wsp(
|
||||
"Subtitle",
|
||||
5753100,
|
||||
420000,
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
|
||||
"",
|
||||
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
|
||||
)
|
||||
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
|
||||
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
|
||||
let check = |d: &Document| {
|
||||
let theme = &d.settings.theme_colors;
|
||||
let objs = ¶s(d)[0].objects;
|
||||
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
|
||||
assert_eq!(children.len(), 3);
|
||||
let shape = |i: usize| match &children[i].obj {
|
||||
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let (kind, fill, free, _) = shape(0);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
|
||||
let (kind, fill, free, _) = shape(1);
|
||||
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
|
||||
let (kind, _, _, story) = shape(2);
|
||||
assert_eq!(kind, ShapeKind::TextBox);
|
||||
let body = d.parts[&story.unwrap()].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
|
||||
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
|
||||
let body = d.parts[sub].body;
|
||||
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
|
||||
};
|
||||
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
|
||||
check(&d);
|
||||
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
#[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>"#;
|
||||
|
|
@ -1745,3 +1840,119 @@ fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves(
|
|||
d = wordcraft_docx::read(&out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Word draws a table of contents' entry links without the Hyperlink character style (measured in
|
||||
/// Word for Windows); the import drops the style from them, keeping the link. A Hyperlink-styled run
|
||||
/// outside a hyperlink, and a hyperlink in another field, keep it.
|
||||
#[test]
|
||||
fn toc_entry_links_lose_the_hyperlink_style() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0066FF"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
|
||||
);
|
||||
let field = |instr: &str, inner: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">{instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{inner}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let link =
|
||||
|t: &str| format!(r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>{t}</w:t></w:r></w:hyperlink>"#);
|
||||
let body = [
|
||||
field(r#" TOC \o "1-3" \h \z \u "#, &link("entry")),
|
||||
field(" TOC ", r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>styled</w:t></w:r>"#),
|
||||
field(" QUOTE x ", &link("quoted")),
|
||||
format!("<w:p>{}</w:p>", link("plain")),
|
||||
r#"<w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let run = |word: &str| {
|
||||
paras(&d).into_iter().find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone())).unwrap_or_else(|| panic!("no run with {word}"))
|
||||
};
|
||||
let entry = run("entry");
|
||||
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "a TOC entry: plain, still a link");
|
||||
assert_eq!(run("styled").style.as_deref(), Some("Hyperlink"), "not in a hyperlink: kept");
|
||||
assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept");
|
||||
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
|
||||
}
|
||||
|
||||
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
|
||||
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
|
||||
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
|
||||
/// shows 6.4.5 in NIH's monitoring-plan template).
|
||||
#[test]
|
||||
fn a_list_level_without_a_list_takes_the_style_s_list() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
|
||||
</w:styles>"#
|
||||
);
|
||||
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
|
||||
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level));
|
||||
assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list");
|
||||
assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles");
|
||||
assert_eq!(num("Loose"), None, "no list in the chain: no numbering");
|
||||
let p = paras(&d);
|
||||
assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2)));
|
||||
assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list");
|
||||
assert_eq!(p[2].props.numbering, None);
|
||||
// Saved and read again it is an ordinary list reference.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
|
||||
}
|
||||
|
||||
/// `w:hideMark` on a cell is read and written back (it was dropped, and the empty rows NIH's
|
||||
/// tables collapse with it showed as blank lines).
|
||||
#[test]
|
||||
fn cell_hide_mark_reads_and_round_trips() {
|
||||
let body = r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:hideMark/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl><w:p/>"#;
|
||||
let hides = |d: &Document| -> Vec<bool> {
|
||||
d.body
|
||||
.iter()
|
||||
.find_map(|b| {
|
||||
if let wordcraft_doc::Block::Table(t) = &**b { Some(t.rows[0].cells.iter().map(|c| c.props.hide_mark).collect()) } else { None }
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let d = read_body(body);
|
||||
assert_eq!(hides(&d), [true, false]);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(hides(&again), [true, false]);
|
||||
}
|
||||
|
||||
/// Text frames (`w:framePr` without a drop cap), as NIH's templates put a footer's page number in
|
||||
/// one: read with their size, anchors, alignment and wrap, and written back (they were dropped on
|
||||
/// save).
|
||||
#[test]
|
||||
fn text_frames_read_and_round_trip() {
|
||||
let body = r#"<w:p><w:pPr><w:framePr w:wrap="around" w:vAnchor="text" w:hAnchor="margin" w:xAlign="center" w:y="1"/></w:pPr><w:r><w:t>3</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:w="2880" w:h="720" w:hRule="exact" w:hSpace="180" w:wrap="notBeside" w:hAnchor="page" w:vAnchor="page" w:x="1440" w:y="2880"/></w:pPr><w:r><w:t>boxed</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:wrap="sideways" w:hAnchor="nowhere" w:w="-5" w:x="NaN"/></w:pPr><w:r><w:t>junk</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="2" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:t>D</w:t></w:r></w:p>
|
||||
<w:p><w:r><w:t>rop cap text</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let p = paras(&d);
|
||||
let f = p[0].props.frame.clone().expect("a frame");
|
||||
assert_eq!(
|
||||
(f.wrap.as_str(), f.v_anchor.as_str(), f.h_anchor.as_str(), f.x_align.as_deref(), f.y),
|
||||
("around", "text", "margin", Some("center"), 0.05)
|
||||
);
|
||||
let g = p[1].props.frame.clone().expect("a frame");
|
||||
assert_eq!((g.w, g.h, g.h_exact, g.h_space, g.wrap.as_str(), g.x, g.y), (Some(144.0), Some(36.0), true, 9.0, "notBeside", 72.0, 144.0));
|
||||
let cap = p.iter().find(|q| q.props.drop_cap.is_some()).expect("the drop cap");
|
||||
assert!(cap.props.frame.is_none(), "a drop cap stays a drop cap");
|
||||
let junk = p.iter().find(|q| q.text.contains("junk")).and_then(|q| q.props.frame.clone()).expect("a frame");
|
||||
assert_eq!((junk.wrap.as_str(), junk.h_anchor.as_str(), junk.w, junk.x), ("auto", "page", None, 0.0), "unknown values fall back");
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let q = paras(&again);
|
||||
assert_eq!(q[0].props.frame, p[0].props.frame);
|
||||
assert_eq!(q[1].props.frame, p[1].props.frame);
|
||||
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ fn every_para_prop_round_trips() {
|
|||
indent_first_chars: Some(-1.0),
|
||||
space_before: Some(6.0),
|
||||
space_after: Some(12.0),
|
||||
space_before_auto: Some(true),
|
||||
space_after_auto: Some(false),
|
||||
line_spacing: Some(LineSpacing::Multiple(1.5)),
|
||||
contextual_spacing: Some(true),
|
||||
mirror_indents: Some(true),
|
||||
|
|
@ -163,6 +165,20 @@ fn every_para_prop_round_trips() {
|
|||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
drop_cap: None,
|
||||
frame: Some(wordcraft_doc::props::TextFrame {
|
||||
w: Some(144.0),
|
||||
h: Some(36.0),
|
||||
h_exact: true,
|
||||
x: 0.0,
|
||||
y: 12.0,
|
||||
x_align: Some("right".into()),
|
||||
y_align: None,
|
||||
h_anchor: "margin".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
h_space: 9.0,
|
||||
v_space: 0.0,
|
||||
}),
|
||||
kinsoku: Some(false),
|
||||
word_wrap: Some(false),
|
||||
overflow_punct: Some(false),
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ fn text_box(d: &mut Document, text: &str, body: TextBody) -> InlineObject {
|
|||
#[test]
|
||||
fn direction_alignment_and_links_round_trip() {
|
||||
let mut d = Document::from_text("a\nb\nc");
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, next: None });
|
||||
let a = text_box(&mut d, "Flows on", TextBody { vert: TextVert::Vert, anchor: VAlign::Center, ..Default::default() });
|
||||
let b = text_box(&mut d, "", TextBody::default());
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, next: None });
|
||||
let c = text_box(&mut d, "", TextBody { vert: TextVert::Stacked, anchor: VAlign::Bottom, ..Default::default() });
|
||||
for (i, o) in [a, b, c].into_iter().enumerate() {
|
||||
d.insert_object(&wordcraft_doc::Pos::body(i, 1), o, &Default::default()).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,9 +31,11 @@ File|Backstage|Recover Unsaved|file.recover
|
|||
File|Backstage|New from Template|file.newFromTemplate
|
||||
File|Backstage|Save as Template|file.saveTemplate
|
||||
Quick Access|Toolbar|Undo|edit.undo
|
||||
Quick Access|Toolbar|Undo List|edit.undoTo
|
||||
Quick Access|Toolbar|Redo|edit.redo
|
||||
Quick Access|Toolbar|Repeat|edit.repeat
|
||||
Quick Access|Toolbar|AutoSave|file.autosave
|
||||
Title Bar|Search|Search (Tell Me)|tools.search
|
||||
Home|Clipboard|Paste|edit.paste
|
||||
Home|Clipboard|Paste Keep Text Only|edit.pasteText
|
||||
Home|Clipboard|Paste Merge Formatting|edit.pasteMerge
|
||||
|
|
@ -356,6 +358,7 @@ Table Design|Borders|Borders|table.borders
|
|||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
Table Layout|Table|Select Table|table.selectTable
|
||||
Table Layout|Table|Select Row|table.selectRow
|
||||
Table Layout|Table|Select Column|table.selectColumn
|
||||
Table Layout|Table|Select Cell|table.selectCell
|
||||
Table Layout|Table|View Gridlines|table.viewGridlines
|
||||
Table Layout|Table|Properties|table.properties
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
|
||||
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
|
||||
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
|
||||
.params(r#"{"unit": "word|sentence|paragraph", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
|
||||
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).pure(),
|
||||
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
|
||||
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
|
||||
let to_end = p::bool(v, "end").unwrap_or(false);
|
||||
|
|
@ -291,12 +291,15 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by.
|
||||
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
|
||||
/// selection bar (the left margin) selects a line; triple-click there the whole document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Unit {
|
||||
Word,
|
||||
Sentence,
|
||||
Line,
|
||||
Paragraph,
|
||||
Document,
|
||||
}
|
||||
|
||||
impl Unit {
|
||||
|
|
@ -304,26 +307,36 @@ impl Unit {
|
|||
match s {
|
||||
"word" => Some(Unit::Word),
|
||||
"sentence" => Some(Unit::Sentence),
|
||||
"line" => Some(Unit::Line),
|
||||
"paragraph" => Some(Unit::Paragraph),
|
||||
"document" => Some(Unit::Document),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The word, sentence or paragraph around `at`, as (start, end). A paragraph includes its mark
|
||||
/// (it ends at the start of the next paragraph in the same container, when there is one).
|
||||
pub fn unit_range(s: &Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
/// The word, sentence, line, paragraph or story around `at`, as (start, end). A paragraph
|
||||
/// includes its mark (it ends at the start of the next paragraph in the same container, when
|
||||
/// there is one), and so does its last line.
|
||||
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
|
||||
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
|
||||
let len = p.len();
|
||||
let mark_end = |s: &Session| match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: len, ..at.clone() },
|
||||
};
|
||||
let (a, b) = match unit {
|
||||
Unit::Word => p.word_at(at.off),
|
||||
Unit::Sentence => p.sentence_at(at.off),
|
||||
Unit::Paragraph => {
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = match s.doc.next_para(at.story, &at.path) {
|
||||
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
|
||||
_ => Pos { off: p.len(), ..at.clone() },
|
||||
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
|
||||
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
|
||||
Unit::Line => {
|
||||
let hint = s.page_hint;
|
||||
return match s.layout().line_span(at, hint) {
|
||||
Some((a, _, true)) => (a, mark_end(s)),
|
||||
Some((a, b, false)) => (a, b),
|
||||
None => (at.clone(), at.clone()),
|
||||
};
|
||||
return (start, end);
|
||||
}
|
||||
};
|
||||
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
|
||||
|
|
@ -350,12 +363,23 @@ fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
|||
select_unit(s, Unit::Paragraph)
|
||||
}
|
||||
|
||||
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
|
||||
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
|
||||
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pos) = v.get("pos").and_then(parse_pos) {
|
||||
s.sel = Selection::caret(s.doc.clamp(&pos));
|
||||
}
|
||||
select_unit(s, Unit::Line)
|
||||
}
|
||||
|
||||
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
|
||||
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
|
||||
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
|
||||
/// anchors at its end, as in Word.
|
||||
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let unit = p::str(v, "unit").and_then(Unit::parse).ok_or_else(|| CmdError::Params("`unit` must be word, sentence or paragraph".into()))?;
|
||||
let unit = p::str(v, "unit")
|
||||
.and_then(Unit::parse)
|
||||
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".into()))?;
|
||||
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
|
||||
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
|
||||
let (start, end) = if start <= end { (start, end) } else { (end, start) };
|
||||
|
|
@ -364,20 +388,18 @@ fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
|
|||
return sel_result(s);
|
||||
}
|
||||
let (us, ue) = unit_range(s, &at, unit);
|
||||
s.sel = if at >= start { Selection { focus: ue.max(end.clone()), anchor: start } } else { Selection { focus: us.min(start), anchor: end } };
|
||||
s.sel = if unit == Unit::Document {
|
||||
Selection { anchor: us, focus: ue }
|
||||
} else if at >= start {
|
||||
Selection { focus: ue.max(end.clone()), anchor: start }
|
||||
} else {
|
||||
Selection { focus: us.min(start), anchor: end }
|
||||
};
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let l = s.layout();
|
||||
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
|
||||
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let needle = p::req_str(v, "text")?;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! of authorities, footnote options.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, heading_letter, sort_key};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
|
||||
|
|
@ -563,7 +564,7 @@ fn citation(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let src = s.doc.sources.iter().find(|x| x.tag == tag).cloned().ok_or_else(|| CmdError::Params(format!("no source `{tag}`")))?;
|
||||
let pages = p::str(v, "pages").unwrap_or("").to_string();
|
||||
let st = style(&s.bib_style);
|
||||
let n = number_of(&s.doc, &bib_order(&s.doc, st), &tag);
|
||||
let n = number_of(&s.doc, &bib_order(&s.doc, st, super::default_lang(s)), &tag);
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let instr = if pages.is_empty() { format!("CITATION {tag}") } else { format!("CITATION {tag} \\p {pages}") };
|
||||
|
|
@ -611,7 +612,7 @@ fn author_key(src: &Source) -> String {
|
|||
|
||||
/// Indices into `doc.sources` in bibliography order: the cited sources (every source while
|
||||
/// nothing is cited), sorted or in order of first citation as the style asks.
|
||||
fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
|
||||
fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) -> Vec<usize> {
|
||||
let cited = cited_tags(doc);
|
||||
let mut idx: Vec<usize> = if cited.is_empty() {
|
||||
(0..doc.sources.len()).collect()
|
||||
|
|
@ -621,8 +622,10 @@ fn bib_order(doc: &Document, st: &CiteStyle) -> Vec<usize> {
|
|||
let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect();
|
||||
doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect()
|
||||
};
|
||||
let key =
|
||||
|i: &usize, by_title: bool| doc.sources.get(*i).map(|x| if by_title { x.title.to_lowercase() } else { author_key(x) }).unwrap_or_default();
|
||||
// Sorted in the document's language (Polish authors `Łukasiewicz` before `Mazur`).
|
||||
let key = |i: &usize, by_title: bool| {
|
||||
sort_key(&doc.sources.get(*i).map(|x| if by_title { x.title.clone() } else { author_key(x) }).unwrap_or_default(), lang.as_deref())
|
||||
};
|
||||
match st.order {
|
||||
Order::Author => idx.sort_by_cached_key(|i| key(i, false)),
|
||||
Order::Title => idx.sort_by_cached_key(|i| key(i, true)),
|
||||
|
|
@ -645,7 +648,7 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> {
|
|||
/// Refresh the results of the CITATION fields in the body.
|
||||
fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
let st = style(&s.bib_style);
|
||||
let order = bib_order(&s.doc, st);
|
||||
let order = bib_order(&s.doc, st, super::default_lang(s));
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
let updates: Vec<(usize, String)> = p
|
||||
|
|
@ -763,7 +766,7 @@ fn page_of(s: &mut Session, pos: &Pos) -> u32 {
|
|||
fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
||||
if kind.starts_with("BIBLIOGRAPHY") {
|
||||
let st = style(&s.bib_style);
|
||||
return bib_order(&s.doc, st)
|
||||
return bib_order(&s.doc, st, super::default_lang(s))
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, i)| s.doc.sources.get(i).map(|x| (entry(x, st.name, k.saturating_add(1)), !st.numbered())))
|
||||
|
|
@ -824,21 +827,22 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
|
|||
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect();
|
||||
}
|
||||
// Index / TOA: group by entry, sort, list pages.
|
||||
let mut grouped: std::collections::BTreeMap<String, Vec<u32>> = Default::default();
|
||||
let lang = super::default_lang(s);
|
||||
let mut grouped: std::collections::BTreeMap<(SortKey, String), Vec<u32>> = Default::default();
|
||||
for (e, p) in with_pages {
|
||||
let pages = grouped.entry(e).or_default();
|
||||
let pages = grouped.entry((sort_key(&e, lang.as_deref()), e)).or_default();
|
||||
if !pages.contains(&p) {
|
||||
pages.push(p);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut letter = None;
|
||||
for (e, pages) in grouped {
|
||||
let first = e.chars().next().map(|c| c.to_ascii_uppercase());
|
||||
for ((_, e), pages) in grouped {
|
||||
let first = heading_letter(&e, lang.as_deref());
|
||||
if kind.starts_with("INDEX") && first != letter {
|
||||
letter = first;
|
||||
letter = first.clone();
|
||||
if let Some(l) = first {
|
||||
out.push((l.to_string(), false));
|
||||
out.push((l, false));
|
||||
}
|
||||
}
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
//! Backspace remove just the block, character formatting applies to each row's piece, paragraph
|
||||
//! formatting to the paragraphs it touches. Typing or pasting first deletes the block; any other
|
||||
//! edit collapses it to its top-left corner. Any ordinary caret move or selection drops it.
|
||||
//! Whole table cells that aren't one run of the text (part of a column, `CellRange`) are a block
|
||||
//! of their paragraphs: edits and formatting go cell by cell; table commands take the cells.
|
||||
//! Extending the selection from one cell into another (Shift+arrows, Shift+click, a drag) makes
|
||||
//! such cells, as in Word (`table::extend_selection`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
|
|
@ -94,7 +98,7 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {
|
|||
let l = s.layout();
|
||||
let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);
|
||||
s.sel = Selection { anchor: a, focus: b };
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments });
|
||||
s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });
|
||||
s.pending = None;
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +120,11 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
|||
s.column = None;
|
||||
s.column_mode = false;
|
||||
}
|
||||
// Placing the caret ends a block even where its corners are (a selected empty cell's are
|
||||
// the same place).
|
||||
if matches!(id, "caret.set" | "select.collapse") {
|
||||
s.column = None;
|
||||
}
|
||||
if s.column_mode && id.starts_with("caret.") && id != "caret.set" {
|
||||
let mut params = v.clone();
|
||||
match params.as_object_mut() {
|
||||
|
|
@ -132,8 +141,25 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
|||
if matches!(id, "caret.set" | "select.collapse") || (mutates && !id.starts_with("format.") && !id.starts_with("para.")) {
|
||||
s.column_mode = false;
|
||||
}
|
||||
// Extending the selection (Shift+arrows, Shift+click, a drag) goes on from where it really
|
||||
// runs, then takes whole cells or rows the way Word does around tables.
|
||||
if id.starts_with("caret.") && p::bool(v, "extend") == Some(true) {
|
||||
let shown = s.sel.clone();
|
||||
s.sel = s.sel_ends().clone();
|
||||
if let Err(e) = run(s, v) {
|
||||
s.sel = shown;
|
||||
return Err(e);
|
||||
}
|
||||
let to = s.sel.focus.clone();
|
||||
super::table::extend_selection(s, to);
|
||||
return super::sel_result(s);
|
||||
}
|
||||
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
|
||||
let cells = s.cell_range().is_some();
|
||||
match id {
|
||||
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
|
||||
// from it without clearing it, as in Word.
|
||||
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
|
||||
"edit.copy" => copy(s, &segs),
|
||||
"edit.cut" => {
|
||||
let r = copy(s, &segs)?;
|
||||
|
|
@ -146,7 +172,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
|
|||
}
|
||||
"format.changeCase" | "format.clear" | "format.resetChar" => per_row(s, run, v, &segs),
|
||||
_ if id.starts_with("format.") || id.starts_with("para.") || id == "select.column" => run(s, v),
|
||||
_ if mutates && (id.starts_with("text.") || id.starts_with("edit.paste")) => {
|
||||
// Pasting formatting (the format painter) leaves the text alone.
|
||||
_ if mutates && (id.starts_with("text.") || (id.starts_with("edit.paste") && id != "edit.pasteFormat")) => {
|
||||
delete(s, &segs)?;
|
||||
run(s, v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,26 @@ use wordcraft_doc::{Pos, StoryRef};
|
|||
use super::{delete_selection, find_pattern, pos_json, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// Undo several steps at once: the Undo button's list (#701). `count` is validated here and
|
||||
/// clamped to the undo depth, so hostile numbers never reach the stack.
|
||||
fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let n = p::u64(v, "count").ok_or_else(|| CmdError::Params("`count` (whole number, 1 or more) is required".into()))?;
|
||||
if n == 0 {
|
||||
return Err(CmdError::Params("`count` must be 1 or more".into()));
|
||||
}
|
||||
let n = usize::try_from(n).unwrap_or(usize::MAX).min(s.undo_depth());
|
||||
if s.step_lifts_protection(false) {
|
||||
return Err(CmdError::Disabled("edit.undoTo: use Stop Protection to lift the document's protection".into()));
|
||||
}
|
||||
let undone = s.undo_steps(n);
|
||||
let mut r = sel_result(s)?;
|
||||
if let Some(m) = r.as_object_mut() {
|
||||
m.insert("undone".into(), json!(undone));
|
||||
m.insert("depth".into(), json!(s.undo_depth()));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn has_sel(s: &Session) -> Option<&'static str> {
|
||||
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
|
||||
}
|
||||
|
|
@ -22,6 +42,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
.key("Mod+Z")
|
||||
.pure(),
|
||||
CommandSpec::new("edit.undoTo", "Undo Actions", "Quick Access Toolbar", undo_to)
|
||||
.params(r#"{"count": n (1 = the newest step, like edit.undo; n undoes the newest n steps at once; at most the undo depth)}"#)
|
||||
.when(|s| if s.can_undo() { None } else { Some("there is nothing to undo") })
|
||||
.pure(),
|
||||
CommandSpec::new("edit.undoHistory", "Undo History", "Quick Access Toolbar", |s, _| {
|
||||
Ok(json!({"undo": s.undo_history(), "depth": s.undo_depth(), "redo": s.redo_label()}))
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
|
||||
if s.step_lifts_protection(true) {
|
||||
return Err(CmdError::Disabled("edit.redo: use Stop Protection to lift the document's protection".into()));
|
||||
|
|
@ -86,12 +114,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp"))
|
||||
.params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#),
|
||||
// Ctrl+H, Home › Replace: the Find and Replace dialog on its Replace tab (#712). Opening
|
||||
// it changes nothing, so it is its own command and never marks the document edited.
|
||||
CommandSpec::new("edit.findReplace", "Find and Replace", "Home › Editing", |s, v| {
|
||||
let tab = match p::str(v, "tab").unwrap_or("replace") {
|
||||
t @ ("find" | "replace" | "goto") => t,
|
||||
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
|
||||
};
|
||||
s.ui_requests.push(json!({"open": tab}));
|
||||
Ok(json!({"open": tab}))
|
||||
})
|
||||
.key("Mod+H")
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#)
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| {
|
||||
let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?;
|
||||
|
|
@ -118,7 +159,7 @@ 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}))
|
||||
|
|
@ -493,8 +534,10 @@ fn read_opts(s: &mut Session, v: &Value) {
|
|||
fn find(s: &mut Session, v: &Value) -> CmdResult {
|
||||
read_opts(s, v);
|
||||
if p::str(v, "text").is_none() {
|
||||
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
|
||||
// Find and Replace dialog is Advanced Find.
|
||||
s.view.nav_pane = true;
|
||||
s.ui_requests.push(json!({"open": "find"}));
|
||||
s.ui_requests.push(json!({"focus": "navSearch"}));
|
||||
return Ok(json!({"count": s.find.results.len()}));
|
||||
}
|
||||
let story = s.sel.focus.story;
|
||||
|
|
@ -605,7 +648,13 @@ fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
fn goto(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(pg) = p::u64(v, "page") {
|
||||
// Relative pages, Go To's "+2" / "-1": from the page the caret is on.
|
||||
let by = v.get("pageBy").and_then(Value::as_i64);
|
||||
let rel = by.map(|by| {
|
||||
let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64;
|
||||
(here + 1).saturating_add(by).max(1) as u64
|
||||
});
|
||||
if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {
|
||||
let l = s.layout();
|
||||
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
|
||||
let target = l.pages.get(idx).and_then(|page| {
|
||||
|
|
|
|||
|
|
@ -167,13 +167,15 @@ fn target(s: &Session) -> Option<(Pos, Pos)> {
|
|||
None
|
||||
}
|
||||
|
||||
/// The non-empty rows of a column selection, if one is active.
|
||||
/// The non-empty rows of a column selection, if one is active. Of whole cells, every paragraph:
|
||||
/// an empty one's mark takes the formatting.
|
||||
fn column_rows(s: &Session) -> Option<Vec<(Pos, Pos)>> {
|
||||
let segs = s.column_segments()?;
|
||||
let cells = s.cell_range().is_some();
|
||||
let rows: Vec<(Pos, Pos)> = segs
|
||||
.iter()
|
||||
.map(|(a, b)| (s.doc.clamp(a), s.doc.clamp(b)))
|
||||
.filter(|(a, b)| a != b && a.path == b.path && a.story == b.story)
|
||||
.filter(|(a, b)| (a != b || cells) && a.path == b.path && a.story == b.story)
|
||||
.map(|(a, b)| if a <= b { (a, b) } else { (b, a) })
|
||||
.collect();
|
||||
// An empty block (column mode just started) formats like a caret.
|
||||
|
|
@ -186,6 +188,10 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
|||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
s.doc.format_range(&a, &b, f)?;
|
||||
// An empty cell's paragraph only had its mark formatted: lay it out again.
|
||||
if a == b {
|
||||
s.doc.para_mut(a.story, &a.path)?.touch();
|
||||
}
|
||||
}
|
||||
return sel_result(s);
|
||||
}
|
||||
|
|
@ -216,6 +222,10 @@ fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
|||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
// An empty cell: its mark's formatting.
|
||||
if a == b {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), &p.mark));
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
|
||||
|
|
@ -351,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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, heading_letter, sort_key};
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
|
|
@ -167,14 +168,15 @@ struct PageRef {
|
|||
|
||||
#[derive(Default)]
|
||||
struct Node {
|
||||
/// The entry's levels as written (the key is their lower-case form).
|
||||
/// The entry's levels as written (the key is their sort keys).
|
||||
levels: Vec<String>,
|
||||
pages: Vec<PageRef>,
|
||||
see: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every XE entry in the body with its page (or page range), grouped by entry.
|
||||
fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
||||
/// Every XE entry in the body with its page (or page range), grouped by entry and sorted in the
|
||||
/// order of language `lang` (Polish files `Łódź` between `L` and `M`).
|
||||
fn collect(s: &mut Session, lang: Option<&str>) -> BTreeMap<Vec<SortKey>, Node> {
|
||||
let mut marks: Vec<(FieldCode, Pos)> = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
|
|
@ -197,7 +199,7 @@ fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
|||
})
|
||||
};
|
||||
let starts = s.doc.bookmarks();
|
||||
let mut tree: BTreeMap<Vec<String>, Node> = BTreeMap::new();
|
||||
let mut tree: BTreeMap<Vec<SortKey>, Node> = BTreeMap::new();
|
||||
for (f, pos) in marks {
|
||||
let text = f.args.first().cloned().unwrap_or_default();
|
||||
let levels: Vec<String> = text.split(':').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).take(9).collect();
|
||||
|
|
@ -207,10 +209,10 @@ fn collect(s: &mut Session) -> BTreeMap<Vec<String>, Node> {
|
|||
// Every level above gets a line of its own, even without pages.
|
||||
for k in 1..levels.len() {
|
||||
let head: Vec<String> = levels.iter().take(k).cloned().collect();
|
||||
tree.entry(head.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
tree.entry(head.iter().map(|x| sort_key(x, lang)).collect()).or_insert_with(|| Node { levels: head, ..Default::default() });
|
||||
}
|
||||
let node =
|
||||
tree.entry(levels.iter().map(|x| x.to_lowercase()).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() });
|
||||
tree.entry(levels.iter().map(|x| sort_key(x, lang)).collect()).or_insert_with(|| Node { levels: levels.clone(), ..Default::default() });
|
||||
if let Some(see) = f.arg('t') {
|
||||
if !node.see.iter().any(|x| x == see) {
|
||||
node.see.push(see.to_string());
|
||||
|
|
@ -278,7 +280,9 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
|||
let sep = f.arg('e').map(str::to_string).unwrap_or_else(|| ", ".into());
|
||||
let run_in = f.has('r');
|
||||
let headings = f.switches.iter().find(|(c, _)| *c == 'h').map(|(_, a)| a.clone().unwrap_or_default());
|
||||
let tree = collect(s);
|
||||
// Entries sort in the language of the index's paragraph (else the document's).
|
||||
let lang = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(|p| super::para_lang(&s.doc, p)).or_else(|| super::default_lang(s));
|
||||
let tree = collect(s, lang.as_deref());
|
||||
let mut lines: Vec<(usize, Line)> = Vec::new();
|
||||
let mut letter: Option<String> = None;
|
||||
let mut keys = tree.keys().peekable();
|
||||
|
|
@ -286,7 +290,7 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
|
|||
let Some(node) = tree.get(key) else { continue };
|
||||
let Some(main) = node.levels.first() else { continue };
|
||||
if let Some(h) = &headings {
|
||||
let first = main.chars().next().map(|c| c.to_uppercase().to_string());
|
||||
let first = heading_letter(main, lang.as_deref());
|
||||
if first != letter {
|
||||
if letter.is_some() || !h.is_empty() {
|
||||
lines.push((0, vec![(if h.is_empty() { String::new() } else { first.clone().unwrap_or_default() }, CharProps::default())]));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub mod smart_art;
|
|||
pub mod speech;
|
||||
pub mod table;
|
||||
pub mod table_draw;
|
||||
pub mod table_mouse;
|
||||
pub mod table_resize;
|
||||
pub mod table_style;
|
||||
pub mod templates;
|
||||
|
|
@ -76,6 +77,7 @@ pub fn registry() -> Registry {
|
|||
v.extend(table_style::specs());
|
||||
v.extend(table_draw::specs());
|
||||
v.extend(table_resize::specs());
|
||||
v.extend(table_mouse::specs());
|
||||
v.extend(review::specs());
|
||||
v.extend(file::specs());
|
||||
v.extend(options::specs());
|
||||
|
|
@ -130,6 +132,19 @@ pub fn sel_result(s: &Session) -> CmdResult {
|
|||
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)}))
|
||||
}
|
||||
|
||||
/// The paragraphs the selection covers: those between its ends (not the next one when it ends at
|
||||
/// that one's start, #418) or, for whole table cells that aren't one run of the text (part of a
|
||||
/// column), just the cells' own.
|
||||
pub(crate) fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
|
||||
if s.cell_range().is_some()
|
||||
&& let Some(segs) = s.column_segments()
|
||||
{
|
||||
return segs.iter().map(|(a, _)| a.path.clone()).collect();
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.para_paths_in(&a, &b)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
|
|
@ -420,6 +435,24 @@ pub fn story_param(s: &Session, v: &Value) -> StoryRef {
|
|||
v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story)
|
||||
}
|
||||
|
||||
/// The language (`w:lang`) of a paragraph's text: that of its first run with text (else of the
|
||||
/// paragraph mark), through styles and the document defaults. Sorting and number parsing follow it.
|
||||
pub fn para_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Option<String> {
|
||||
let run = p.runs.iter().find(|r| r.len > 0).map(|r| &r.props).unwrap_or(&p.mark);
|
||||
doc.styles.resolve_char(p.props.style.as_deref(), run).lang
|
||||
}
|
||||
|
||||
/// The document's default text language (`w:docDefaults`, the Normal style).
|
||||
pub fn doc_lang(doc: &wordcraft_doc::Document) -> Option<String> {
|
||||
doc.styles.resolve_char(None, &wordcraft_doc::CharProps::default()).lang
|
||||
}
|
||||
|
||||
/// The language of text that names none: the document's default, else the editing language (a
|
||||
/// Polish interface writes Polish), else none (English). As AutoCorrect decides it.
|
||||
pub fn default_lang(s: &Session) -> Option<String> {
|
||||
doc_lang(&s.doc).or_else(|| s.editing_lang.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod iso_tests {
|
||||
use super::iso_from_unix_secs;
|
||||
|
|
|
|||
|
|
@ -480,14 +480,24 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
|||
"oddPage" => SectionStart::OddPage,
|
||||
x => return Err(CmdError::Params(format!("unknown break `{x}`"))),
|
||||
};
|
||||
if s.sel.focus.story != wordcraft_doc::StoryRef::Body || s.sel.focus.path.depth() > 0 {
|
||||
// A section break goes just before the selection and never replaces it, as in Word (#852:
|
||||
// selected text was deleted, so the first section lost its text).
|
||||
let old = s.sel.clone();
|
||||
let (at, end) = old.ordered();
|
||||
if at.story != wordcraft_doc::StoryRef::Body || at.path.depth() > 0 {
|
||||
return Err(CmdError::Failed("section breaks go in the main text".into()));
|
||||
}
|
||||
// Section break: the paragraph before the caret ends a section with the current props; the
|
||||
// new section (after) takes them too and starts as requested.
|
||||
let at = super::delete_selection(s)?;
|
||||
s.sel = Selection::caret(at.clone());
|
||||
let cur = sect(s);
|
||||
let new = s.doc.split_paragraph(&at)?;
|
||||
let new = match s.doc.split_paragraph(&at) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
s.sel = old;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let ended = at.path.clone();
|
||||
let para = s.doc.para_mut(at.story, &ended)?;
|
||||
let mut props = cur;
|
||||
|
|
@ -496,11 +506,32 @@ fn breaks(s: &mut Session, v: &Value) -> CmdResult {
|
|||
para.section = Some(Box::new(props.clone()));
|
||||
props.start = start;
|
||||
*s.doc.section_mut(next_block) = props;
|
||||
s.sel = Selection::caret(Pos { off: 0, ..new });
|
||||
// The selection keeps its text, which now starts the new section.
|
||||
let first = Pos { off: 0, ..new };
|
||||
let last = shifted_by_split(&end, &at, &first);
|
||||
s.sel = if old.anchor <= old.focus { Selection { anchor: first, focus: last } } else { Selection { anchor: last, focus: first } };
|
||||
let _ = Block::Para;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Where `p` lands after the top-level body paragraph at `at` was split there, the second half
|
||||
/// starting at `new`: later text in that paragraph moves into the new one, later blocks move down.
|
||||
fn shifted_by_split(p: &Pos, at: &Pos, new: &Pos) -> Pos {
|
||||
if p.story != at.story {
|
||||
return p.clone();
|
||||
}
|
||||
if p.path == at.path {
|
||||
return if p.off >= at.off { Pos { off: p.off.saturating_sub(at.off), ..new.clone() } } else { p.clone() };
|
||||
}
|
||||
let mut q = p.clone();
|
||||
if let (Some(first), Some(i)) = (q.path.0.first_mut(), at.path.0.first())
|
||||
&& *first > *i
|
||||
{
|
||||
*first = first.saturating_add(1);
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -532,6 +563,56 @@ mod tests {
|
|||
assert_eq!(sect(&s).text_direction, TextDirection::Down);
|
||||
}
|
||||
|
||||
/// #852: a continuous section break with text selected goes before the selection and keeps
|
||||
/// it, as in Word; it used to delete the text, so the first section came out empty. Saved and
|
||||
/// opened again, both sections' text lays out on page 1, the second below the first.
|
||||
#[test]
|
||||
fn section_break_keeps_the_selected_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "one");
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
let texts = |d: &wordcraft_doc::Document| -> Vec<String> {
|
||||
d.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().to_string()) } else { None }).collect()
|
||||
};
|
||||
assert_eq!(texts(&s.doc), ["", "one", "two"]);
|
||||
// A backward selection in the middle of a paragraph keeps its text and direction too.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("alpha beta\ngamma"));
|
||||
let p = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
|
||||
s.sel = Selection { anchor: p(10), focus: p(6) };
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
assert_eq!(s.selected_text(), "beta");
|
||||
assert!(s.sel.anchor > s.sel.focus);
|
||||
assert_eq!(texts(&s.doc), ["alpha ", "beta", "gamma"]);
|
||||
|
||||
// The issue's document: "one", a continuous break, "two" — both on page 1 after a round trip.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("one\ntwo"));
|
||||
s.run("select.text", &json!({"text": "one"})).unwrap();
|
||||
s.run("caret.end", &json!({})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "continuous"})).unwrap();
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.sections().len(), 2);
|
||||
assert_eq!(back.last_section.start, SectionStart::Continuous);
|
||||
let mut s = Session::new(back);
|
||||
let l = s.layout();
|
||||
assert_eq!(l.pages.len(), 1);
|
||||
let mut lines: Vec<(String, f32)> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
crate::layout::Placed::Lines { path, y, .. } => {
|
||||
let Some(Block::Para(p)) = s.doc.body.get(path.last()).map(|b| &**b) else { return None };
|
||||
(!p.plain_text().is_empty()).then(|| (p.plain_text().to_string(), *y))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
lines.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let order: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(order, ["one", "two"]);
|
||||
}
|
||||
|
||||
/// More Columns: unequal widths with a line between, as one undo step.
|
||||
#[test]
|
||||
fn unequal_columns_with_a_line_between() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
|
|
@ -78,6 +79,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
CommandSpec::new("para.oneAndHalf", "1.5 Line Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.5))).key("Mod+5"),
|
||||
CommandSpec::new("para.spacing", "Paragraph Spacing", "Layout › Paragraph", |s, v| {
|
||||
let (b, a) = (p::f32(v, "before"), p::f32(v, "after"));
|
||||
let (b_auto, a_auto) = (p::bool(v, "beforeAuto"), p::bool(v, "afterAuto"));
|
||||
// A number replaces automatic spacing, as typing one in Word's Paragraph dialog does.
|
||||
let rp = cur(s);
|
||||
let b_auto = b_auto.or((b.is_some() && rp.before_auto).then_some(false));
|
||||
let a_auto = a_auto.or((a.is_some() && rp.after_auto).then_some(false));
|
||||
fmt(s, &|p| {
|
||||
if let Some(x) = b {
|
||||
p.space_before = Some(x.clamp(0.0, 1584.0));
|
||||
|
|
@ -85,17 +91,35 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
if let Some(x) = a {
|
||||
p.space_after = Some(x.clamp(0.0, 1584.0));
|
||||
}
|
||||
if let Some(on) = b_auto {
|
||||
p.space_before_auto = Some(on);
|
||||
}
|
||||
if let Some(on) = a_auto {
|
||||
p.space_after_auto = Some(on);
|
||||
}
|
||||
})
|
||||
})
|
||||
.params(r#"{"before"?: pt, "after"?: pt}"#),
|
||||
.params(r#"{"before"?: pt, "after"?: pt, "beforeAuto"?: bool, "afterAuto"?: bool (automatic, HTML-style spacing: 14 pt)}"#),
|
||||
CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let has = cur(s).space_before > 0.0;
|
||||
fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 }))
|
||||
let rp = cur(s);
|
||||
let has = rp.space_before > 0.0;
|
||||
fmt(s, &|p| {
|
||||
p.space_before = Some(if has { 0.0 } else { 12.0 });
|
||||
if rp.before_auto {
|
||||
p.space_before_auto = Some(false);
|
||||
}
|
||||
})
|
||||
})
|
||||
.key("Mod+0"),
|
||||
CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
|
||||
let has = cur(s).space_after > 0.0;
|
||||
fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 }))
|
||||
let rp = cur(s);
|
||||
let has = rp.space_after > 0.0;
|
||||
fmt(s, &|p| {
|
||||
p.space_after = Some(if has { 0.0 } else { 8.0 });
|
||||
if rp.after_auto {
|
||||
p.space_after_auto = Some(false);
|
||||
}
|
||||
})
|
||||
}),
|
||||
// Back to the paragraph style: direct indents, spacing, alignment… go; the style, list
|
||||
// membership and drop cap stay. ⌘Q quits on macOS, so Word for Mac uses ⌃Q.
|
||||
|
|
@ -230,8 +254,12 @@ fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
|
|||
}
|
||||
|
||||
pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_paragraphs(&a, &b, f)?;
|
||||
let story = s.sel.focus.story;
|
||||
for path in super::selected_paths(s) {
|
||||
let para = s.doc.para_mut(story, &path)?;
|
||||
f(&mut para.props);
|
||||
para.touch();
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
|
|
@ -311,8 +339,8 @@ fn asian_typography(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
|
|
@ -333,8 +361,8 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
|
||||
pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
let paths = s.doc.para_paths_in(&a, &b);
|
||||
let a = s.sel.ordered().0;
|
||||
let paths = super::selected_paths(s);
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
for path in paths {
|
||||
let (num, left) = {
|
||||
|
|
@ -396,7 +424,7 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
return super::format::apply(s, &|c| c.style = Some(id.clone()));
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
s.doc.format_paragraphs(&a, &b, &|p| {
|
||||
fmt(s, &|p| {
|
||||
p.style = Some(id.clone());
|
||||
// Applying a heading or Normal removes direct list numbering (except list styles).
|
||||
if (id.starts_with("Heading") || id == "Title" || id == "Normal") && p.numbering.is_some_and(|n| n.num != 0) {
|
||||
|
|
@ -433,10 +461,10 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
|
||||
let off = p::bool(v, "off").unwrap_or(false);
|
||||
let (a, b) = s.sel.ordered();
|
||||
let a = s.sel.ordered().0;
|
||||
// Toggle: if every selected paragraph already has this kind of list, remove it.
|
||||
let current: Vec<Option<NumRef>> =
|
||||
s.doc.para_paths_in(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
super::selected_paths(s).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
|
||||
let same_kind = |n: &NumRef| {
|
||||
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
|
||||
let want = wordcraft_doc::numbering::levels_for(kind);
|
||||
|
|
@ -802,28 +830,42 @@ fn sort(s: &mut Session, v: &Value) -> CmdResult {
|
|||
return Err(CmdError::Failed("select paragraphs in one place to sort".into()));
|
||||
}
|
||||
let (i0, i1) = (a.path.last(), b.path.last());
|
||||
let lang = s.doc.para(a.story, &a.path).and_then(|p| super::para_lang(&s.doc, p)).or_else(|| super::default_lang(s));
|
||||
let bl = s.doc.container_mut(a.story, &a.path)?;
|
||||
let Some(slice) = bl.get_mut(i0..=i1) else { return Err(CmdError::Failed("bad range".into())) };
|
||||
if slice.iter().any(|b| !matches!(**b, Block::Para(_))) {
|
||||
return Err(CmdError::Failed("sorting tables isn't supported here; use Table › Sort".into()));
|
||||
}
|
||||
let key = |b: &std::sync::Arc<Block>| b.as_para().map(|p| p.plain_text().to_lowercase()).unwrap_or_default();
|
||||
slice.sort_by(|x, y| {
|
||||
let o = natural_cmp(&key(x), &key(y));
|
||||
let mut keyed: Vec<_> =
|
||||
slice.iter().map(|b| (natural_key(&b.as_para().map(|p| p.plain_text()).unwrap_or_default(), lang.as_deref()), b.clone())).collect();
|
||||
keyed.sort_by(|(x, _), (y, _)| {
|
||||
let o = natural_cmp(x, y);
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
for (dst, (_, blk)) in slice.iter_mut().zip(keyed) {
|
||||
*dst = blk;
|
||||
}
|
||||
// Keep a section break on the last paragraph of the range where it was.
|
||||
s.sel = crate::Selection { anchor: Pos { off: 0, ..a }, focus: b };
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Compare strings with embedded numbers numerically ("item 2" < "item 10").
|
||||
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let num = |s: &str| s.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
|
||||
match (num(a), num(b)) {
|
||||
(Some(x), Some(y)) if x != y => x.total_cmp(&y),
|
||||
_ => a.cmp(b),
|
||||
}
|
||||
/// A paragraph's sort key: the number it starts with, then its text in its language's order.
|
||||
fn natural_key(text: &str, lang: Option<&str>) -> (Option<f64>, SortKey) {
|
||||
let num = text.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
|
||||
(num, sort_key(text, lang))
|
||||
}
|
||||
|
||||
/// Paragraphs starting with a number come first, in numeric order ("item 2" < "item 10" within
|
||||
/// the same number, by text); the rest follow in collation order. A total order.
|
||||
fn natural_cmp(a: &(Option<f64>, SortKey), b: &(Option<f64>, SortKey)) -> std::cmp::Ordering {
|
||||
let num = match (a.0, b.0) {
|
||||
(Some(x), Some(y)) => x.total_cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
};
|
||||
num.then_with(|| a.1.cmp(&b.1))
|
||||
}
|
||||
|
||||
fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::locale_number::{format_number, parse_number};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
|
@ -175,6 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
select_cells(s, &tp, Some((r, None)))
|
||||
})
|
||||
.pure()),
|
||||
t(CommandSpec::new("table.selectColumn", "Select Column", "Table Layout › Table › Select", select_column).pure()),
|
||||
t(CommandSpec::new("table.selectCell", "Select Cell", "Table Layout › Table › Select", |s, _| {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
select_cells(s, &tp, Some((r, Some(c))))
|
||||
|
|
@ -384,6 +387,13 @@ fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult {
|
|||
|
||||
/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell).
|
||||
fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
|
||||
// Whole cells selected (a column, say): just those, by grid column, so merged cells count.
|
||||
if let Some(range) = s.cell_range()
|
||||
&& s.sel.focus.path.cell().is_some_and(|(tp, _, _)| tp == range.table)
|
||||
&& let Some(t) = s.doc.table(range.story, &range.table)
|
||||
{
|
||||
return range.cells(t);
|
||||
}
|
||||
let (a, b) = s.sel.ordered();
|
||||
let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() };
|
||||
if ta != tb {
|
||||
|
|
@ -454,7 +464,8 @@ fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
|
||||
pub(super) fn row(s: &mut Session, below: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let cells = selected_cells(s);
|
||||
let n = cells.iter().map(|x| x.0).collect::<std::collections::BTreeSet<_>>().len().max(1);
|
||||
|
|
@ -465,7 +476,8 @@ fn row(s: &mut Session, below: bool) -> CmdResult {
|
|||
fix_caret(s, &tp, if below { r + 1 } else { r }, c)
|
||||
}
|
||||
|
||||
fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
/// Insert Left / Right: a column beside the caret's cell; the caret goes into it.
|
||||
pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
|
||||
let g = t.grid_col(r, c);
|
||||
|
|
@ -618,22 +630,47 @@ fn cell_text(c: &wordcraft_doc::Cell) -> String {
|
|||
c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// The language of a cell's text: that of its first paragraph with text.
|
||||
fn cell_lang(doc: &wordcraft_doc::Document, c: &wordcraft_doc::Cell) -> Option<String> {
|
||||
c.blocks.iter().filter_map(|b| b.as_para()).find(|p| !p.plain_text().trim().is_empty()).and_then(|p| super::para_lang(doc, p))
|
||||
}
|
||||
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
with_table(s, |t| {
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header)));
|
||||
let Some(body) = t.rows.get_mut(skip..) else { return };
|
||||
body.sort_by(|a, b| {
|
||||
let ka = a.cells.get(col).map(cell_text).unwrap_or_default();
|
||||
let kb = b.cells.get(col).map(cell_text).unwrap_or_default();
|
||||
let o = match (ka.trim().replace(',', "").parse::<f64>(), kb.trim().replace(',', "").parse::<f64>()) {
|
||||
(Ok(x), Ok(y)) => x.total_cmp(&y),
|
||||
_ => ka.to_lowercase().cmp(&kb.to_lowercase()),
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| super::default_lang(s));
|
||||
// A total order (numbers, then text), so sorting never sees inconsistent comparisons.
|
||||
let keys: Vec<(Option<f64>, SortKey)> = column()
|
||||
.map(|c| {
|
||||
let text = c.map(cell_text).unwrap_or_default();
|
||||
let num_lang = c.and_then(|c| cell_lang(&s.doc, c)).or_else(|| lang.clone());
|
||||
let num = parse_number(text.trim(), num_lang.as_deref()).filter(|x| x.is_finite());
|
||||
(num, if num.is_some() { SortKey::default() } else { sort_key(text.trim(), lang.as_deref()) })
|
||||
})
|
||||
.collect();
|
||||
with_table(s, move |t| {
|
||||
if skip > t.rows.len() {
|
||||
return;
|
||||
}
|
||||
let body = t.rows.split_off(skip);
|
||||
let mut rows: Vec<_> = keys.iter().cloned().zip(body).collect();
|
||||
rows.sort_by(|((a, ka), _), ((b, kb), _)| {
|
||||
let o = match (a, b) {
|
||||
(Some(x), Some(y)) => x.total_cmp(y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => ka.cmp(kb),
|
||||
};
|
||||
if desc { o.reverse() } else { o }
|
||||
});
|
||||
t.rows.extend(rows.into_iter().map(|(_, r)| r));
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -664,14 +701,27 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
|||
let (tp, r, c) = cell(s)?;
|
||||
let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase();
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let nums: Vec<f64> = if f.contains("LEFT") {
|
||||
t.rows
|
||||
.get(r)
|
||||
.map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect())
|
||||
.unwrap_or_default()
|
||||
let story = s.sel.focus.story;
|
||||
// Each cell's number is read in that cell's language (`3,50` in Polish is three and a half).
|
||||
let sources: Vec<&wordcraft_doc::Cell> = if f.contains("LEFT") {
|
||||
t.rows.get(r).map(|row| row.cells.iter().take(c).collect()).unwrap_or_default()
|
||||
} else {
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()
|
||||
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).collect()
|
||||
};
|
||||
let default_lang = super::default_lang(s);
|
||||
let nums: Vec<f64> = sources
|
||||
.iter()
|
||||
.filter_map(|x| parse_number(cell_text(x).trim(), cell_lang(&s.doc, x).or_else(|| default_lang.clone()).as_deref()))
|
||||
.filter(|x| x.is_finite())
|
||||
.collect();
|
||||
// The result is written in the language of the formula's cell, else of the numbers it adds.
|
||||
let out_lang = s
|
||||
.doc
|
||||
.para(story, &s.sel.focus.path)
|
||||
.filter(|p| !p.plain_text().trim().is_empty())
|
||||
.and_then(|p| super::para_lang(&s.doc, p))
|
||||
.or_else(|| sources.iter().find_map(|x| cell_lang(&s.doc, x)))
|
||||
.or(default_lang);
|
||||
let val = if f.starts_with("=AVERAGE") {
|
||||
if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 }
|
||||
} else if f.starts_with("=COUNT") {
|
||||
|
|
@ -685,7 +735,7 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
|||
} else {
|
||||
nums.iter().sum()
|
||||
};
|
||||
let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") };
|
||||
let text = format_number(val, out_lang.as_deref());
|
||||
let props = s.typing_props();
|
||||
let at = s.sel.focus.clone();
|
||||
let end = s.doc.insert_object(
|
||||
|
|
@ -700,26 +750,164 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult {
|
|||
fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option<usize>)>) -> CmdResult {
|
||||
let story = s.sel.focus.story;
|
||||
let Some(t) = s.doc.table(story, tp) else { return sel_result(s) };
|
||||
let (r0, r1, c0, c1) = match which {
|
||||
None => (0, t.rows.len().saturating_sub(1), 0, usize::MAX),
|
||||
Some((r, None)) => (r, r, 0, usize::MAX),
|
||||
Some((r, Some(c))) => (r, r, c, c),
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match which {
|
||||
None => ((0, last_row), (0, last_col)),
|
||||
Some((r, None)) => ((r, r), (0, last_col)),
|
||||
Some((r, Some(c))) => ((r, r), grid_cols(t, r, c)),
|
||||
};
|
||||
let last_c = t.rows.get(r1).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0).min(c1);
|
||||
let mut a = tp.0.clone();
|
||||
a.extend([r0 as u32, c0 as u32, 0]);
|
||||
let mut b = tp.0.clone();
|
||||
b.extend([r1 as u32, last_c as u32]);
|
||||
let end_path = s.doc.para_paths(story).into_iter().rfind(|q| q.0.starts_with(&b)).unwrap_or_else(|| {
|
||||
let mut x = b.clone();
|
||||
x.push(0);
|
||||
Path(x)
|
||||
});
|
||||
let off = s.doc.para(story, &end_path).map(|x| x.len()).unwrap_or(0);
|
||||
s.sel = Selection { anchor: Pos { story, path: Path(a), off: 0 }, focus: Pos { story, path: end_path, off } };
|
||||
select_range(s, story, tp, rows, cols)
|
||||
}
|
||||
|
||||
/// The grid columns cell `c` of row `r` covers (first, last).
|
||||
pub(super) fn grid_cols(t: &Table, r: usize, c: usize) -> (usize, usize) {
|
||||
let g = t.grid_col(r, c);
|
||||
let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, wordcraft_doc::Cell::span);
|
||||
(g, g + span - 1)
|
||||
}
|
||||
|
||||
/// Table Layout › Table › Select › Select Column: the grid columns of the selected cells, every row.
|
||||
fn select_column(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let (tp, r, c) = cell(s)?;
|
||||
let story = s.sel.focus.story;
|
||||
let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
|
||||
let (mut g0, mut g1) = grid_cols(t, r, c);
|
||||
for (r, c) in selected_cells(s) {
|
||||
let (a, b) = grid_cols(t, r, c);
|
||||
(g0, g1) = (g0.min(a), g1.max(b));
|
||||
}
|
||||
let rows = (0, t.rows.len().saturating_sub(1));
|
||||
select_range(s, story, &tp, rows, (g0, g1))
|
||||
}
|
||||
|
||||
/// Select whole cells of table `tp`: rows `rows`, and of them every cell reaching into grid
|
||||
/// columns `cols` (both inclusive, either order). The selection runs from the first cell's start
|
||||
/// to the last cell's end; a block of the cells' paragraphs (`CellRange`) says which cells they
|
||||
/// are, so the app shades them and, when they aren't one run of the text (part of a column),
|
||||
/// edits and formatting leave the cells between the corners alone.
|
||||
pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> CmdResult {
|
||||
let t = s.doc.table(story, tp).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let rows = (rows.0.min(rows.1).min(last_row), rows.0.max(rows.1).min(last_row));
|
||||
let cols = (cols.0.min(cols.1).min(last_col), cols.0.max(cols.1).min(last_col));
|
||||
let stream = rows.0 == rows.1 || (cols.0 == 0 && cols.1 == last_col);
|
||||
let range = crate::CellRange { story, table: tp.clone(), rows, cols, stream };
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
|
||||
// Every paragraph of the picked cells (nested tables' too), in document order.
|
||||
let depth = tp.0.len();
|
||||
let in_picked = |q: &Path| match (q.0.get(depth), q.0.get(depth + 1)) {
|
||||
(Some(r), Some(c)) => q.0.len() > depth + 2 && q.0.starts_with(&tp.0) && picked.contains(&(*r as usize, *c as usize)),
|
||||
_ => false,
|
||||
};
|
||||
let segments: Vec<(Pos, Pos)> = s
|
||||
.doc
|
||||
.para_paths(story)
|
||||
.into_iter()
|
||||
.filter(|q| in_picked(q))
|
||||
.map(|q| {
|
||||
let len = s.doc.para(story, &q).map_or(0, Paragraph::len);
|
||||
(Pos { story, path: q.clone(), off: 0 }, Pos { story, path: q, off: len })
|
||||
})
|
||||
.collect();
|
||||
let (Some(first), Some(last)) = (segments.first(), segments.last()) else {
|
||||
return Err(CmdError::Failed("there are no cells to select there".into()));
|
||||
};
|
||||
// The end corner is in the last cell's own last paragraph (not a nested table's), so it
|
||||
// names the cell.
|
||||
let focus = segments.iter().rev().find(|(_, b)| b.path.0.len() == depth + 3).map_or(&last.1, |(_, b)| b).clone();
|
||||
let sel = Selection { anchor: first.0.clone(), focus };
|
||||
s.sel = sel.clone();
|
||||
s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) });
|
||||
s.column_mode = false;
|
||||
s.widened = None;
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Extend the selection from its anchor to `to` the way Word does around tables: from one cell
|
||||
/// into another of the same table it takes whole cells, the rectangle between them; between a
|
||||
/// table's cell and text outside the table, whole rows, from the cell's row out to the table's
|
||||
/// edge facing the other end. Where the selection really runs is kept (`Session::sel_ends`), so
|
||||
/// extending it again goes on from there.
|
||||
pub(crate) fn extend_selection(s: &mut Session, to: Pos) {
|
||||
let ends = Selection { anchor: s.sel.anchor.clone(), focus: to };
|
||||
// Shift+Up and Shift+Down keep their goal column.
|
||||
let goal_x = s.goal_x;
|
||||
s.sel = ends.clone();
|
||||
s.column = None;
|
||||
widen(s, &ends);
|
||||
s.widened = if s.sel == ends { None } else { Some(crate::session::Widened { sel: s.sel.clone(), ends }) };
|
||||
s.goal_x = goal_x;
|
||||
}
|
||||
|
||||
/// Widen the selection between `ends` (already `s.sel`) by Word's table rule (see
|
||||
/// `extend_selection`).
|
||||
fn widen(s: &mut Session, ends: &Selection) {
|
||||
let (a, f) = (&ends.anchor, &ends.focus);
|
||||
if a.story != f.story {
|
||||
return;
|
||||
}
|
||||
// Down through the tables both ends are in, one nesting level (three indexes) at a time.
|
||||
let mut i = 0;
|
||||
while let (Some(ab), Some(fb)) = (a.path.0.get(i), f.path.0.get(i)) {
|
||||
match (cell_at(&a.path.0, i), cell_at(&f.path.0, i)) {
|
||||
// The same cell: look inside it.
|
||||
(Some(x), Some(y)) if ab == fb && x == y => i += 3,
|
||||
// Two cells of one table: the rectangle between them.
|
||||
(Some((tp, ra, ca)), Some((_, rf, cf))) if ab == fb => {
|
||||
let cols = s.doc.table(a.story, &tp).map(|t| {
|
||||
let ((g0, g1), (h0, h1)) = (grid_cols(t, ra, ca), grid_cols(t, rf, cf));
|
||||
(g0.min(h0), g1.max(h1))
|
||||
});
|
||||
if let Some(cols) = cols {
|
||||
// A table without paragraphs keeps the plain selection.
|
||||
let _ = select_range(s, a.story, &tp, (ra, rf), cols);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Different blocks: an end in a table takes its whole row, out to the table's edge
|
||||
// facing the other end.
|
||||
(x, y) if ab != fb => {
|
||||
let forward = ab < fb;
|
||||
if let Some((tp, r, _)) = x
|
||||
&& let Some((start, end)) = row_ends(&s.doc, a.story, &tp, r)
|
||||
{
|
||||
s.sel.anchor = if forward { start } else { end };
|
||||
}
|
||||
if let Some((tp, r, _)) = y
|
||||
&& let Some((start, end)) = row_ends(&s.doc, f.story, &tp, r)
|
||||
{
|
||||
s.sel.focus = if forward { end } else { start };
|
||||
}
|
||||
return;
|
||||
}
|
||||
// One paragraph.
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell of the table at index `i` of paragraph path `p`, if the paragraph is in one there:
|
||||
/// (the table's path, row, cell).
|
||||
fn cell_at(p: &[u32], i: usize) -> Option<(Path, usize, usize)> {
|
||||
match (p.get(..=i), p.get(i + 1), p.get(i + 2)) {
|
||||
(Some(t), Some(r), Some(c)) if p.len() > i + 3 => Some((Path(t.to_vec()), *r as usize, *c as usize)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where row `r` of table `tp` starts and ends: its first cell's start and its last cell's end.
|
||||
fn row_ends(doc: &Document, story: StoryRef, tp: &Path, r: usize) -> Option<(Pos, Pos)> {
|
||||
let mut row = tp.0.clone();
|
||||
row.push(u32::try_from(r).ok()?);
|
||||
let mut paths = doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&row));
|
||||
let first = paths.next()?;
|
||||
let last = paths.next_back().unwrap_or_else(|| first.clone());
|
||||
let len = doc.para(story, &last).map_or(0, Paragraph::len);
|
||||
Some((Pos { story, path: first, off: 0 }, Pos { story, path: last, off: len }))
|
||||
}
|
||||
|
||||
fn from_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' };
|
||||
let (a, b) = s.sel.ordered();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,864 @@
|
|||
//! The rest of Word's table mouse (resizing is `table_resize`): selection areas just above a
|
||||
//! column, left of a row and along a cell's left edge inside it select the column, row or cell,
|
||||
//! and a drag from one selects every one up to the pointer; insert buttons left of a row border
|
||||
//! and above a column border add a row or column there; the move handle just past the table's
|
||||
//! top-left corner selects the table or, dragged, moves it (the table then floats where it's
|
||||
//! dropped, the text wrapping around it, as in Word); a double-click on a column border fits the
|
||||
//! column left of it to its contents. The app sends each click or finished drag here in page
|
||||
//! points, so each is one command (one undo step when it changes the document).
|
||||
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
use super::table_resize::{self, CellBox, Edge, REACH, ResizeTarget};
|
||||
use super::{sel_result, table};
|
||||
use crate::{CellRange, CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// The column selection area: from this far above a table's top border (points) to this far
|
||||
/// below it.
|
||||
const COLUMN_ABOVE: f32 = 6.0;
|
||||
const COLUMN_BELOW: f32 = 1.5;
|
||||
/// How far left of a table (points) the row selection area and the row insert buttons reach.
|
||||
const ROW_LEFT: f32 = 24.0;
|
||||
/// The cell selection area's width inside a cell's left edge (points), at the least; it starts
|
||||
/// past the column border's resize reach.
|
||||
const CELL_EDGE: f32 = 5.5;
|
||||
/// The insert buttons answer this near a border (points), and above the table this far out.
|
||||
const INSERT_REACH: f32 = 3.5;
|
||||
const INSERT_OUT: f32 = 14.0;
|
||||
/// An insert button's centre sits this far out from the table's edge (points).
|
||||
const BUTTON_OUT: f32 = 8.0;
|
||||
/// The move handle's side and its gap from the table's top-left corner (points).
|
||||
const HANDLE: f32 = 8.0;
|
||||
const HANDLE_GAP: f32 = 2.0;
|
||||
/// The farthest a drag moves a table (points; Word's 22 inches).
|
||||
const MAX_MOVE: f32 = 1584.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at)
|
||||
.params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a click on a table's insert button: left of a row border adds a row there, above a column border a column; the new row or column is selected, as in Word)"#,
|
||||
),
|
||||
CommandSpec::new("table.moveDrag", "Move Table by Dragging", "Table Layout › Table", move_drag).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "reach"?: pt} (a drag from the move handle past a table's top-left corner: the table floats where it's dropped, the text wrapping around it; tables in the main text only)"#,
|
||||
),
|
||||
CommandSpec::new("table.fitColumn", "AutoFit Column by Double-Clicking", "Table Layout › Cell Size", fit_column).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a double-click on a column border: the column left of it fits its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin), as in Word)"#,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// What the pointer is over.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Spot {
|
||||
/// The move handle.
|
||||
Handle,
|
||||
/// The insert button of horizontal border `n` (0 = the table's top, `n` = the bottom of row
|
||||
/// `n - 1`).
|
||||
InsertRow(usize),
|
||||
/// The insert button of grid line `n` (0 = the table's left edge).
|
||||
InsertColumn(usize),
|
||||
/// The selection area above grid column `n`.
|
||||
Column(usize),
|
||||
/// The selection area left of row `n`.
|
||||
Row(usize),
|
||||
/// The selection area along cell (`row`, `cell`)'s left edge.
|
||||
Cell(usize, usize),
|
||||
}
|
||||
|
||||
/// A spot and the table's drawn geometry on the page (`geom.edge` means nothing here).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Target {
|
||||
pub spot: Spot,
|
||||
pub geom: ResizeTarget,
|
||||
}
|
||||
|
||||
/// An insert button: its centre and the border it inserts at, end to end (page points).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct InsertMark {
|
||||
pub center: (f32, f32),
|
||||
pub from: (f32, f32),
|
||||
pub to: (f32, f32),
|
||||
}
|
||||
|
||||
/// The move handle's square (page points), just past the table's top-left corner.
|
||||
pub fn handle(geom: &ResizeTarget) -> Rect {
|
||||
Rect::new(geom.left() - HANDLE_GAP - HANDLE, geom.top() - HANDLE_GAP - HANDLE, HANDLE, HANDLE)
|
||||
}
|
||||
|
||||
/// Whether the table's first row is on this page (its move handle is there).
|
||||
fn starts_here(geom: &ResizeTarget) -> bool {
|
||||
geom.rows.first().is_some_and(|r| r.row == 0)
|
||||
}
|
||||
|
||||
fn on_handle(geom: &ResizeTarget, x: f32, y: f32, reach: f32) -> bool {
|
||||
let h = handle(geom);
|
||||
starts_here(geom) && x >= h.x - reach / 2.0 && x <= h.right() + reach / 2.0 && y >= h.y - reach / 2.0 && y <= h.bottom() + reach / 2.0
|
||||
}
|
||||
|
||||
fn clean_reach(reach: f32) -> f32 {
|
||||
if reach.is_finite() { reach.clamp(0.5, 24.0) } else { REACH }
|
||||
}
|
||||
|
||||
/// Of `lines` (index, position), the one nearest `at` within `reach`.
|
||||
fn nearest(lines: impl Iterator<Item = (usize, f32)>, at: f32, reach: f32) -> Option<usize> {
|
||||
lines.map(|(i, v)| (i, (at - v).abs())).filter(|(_, d)| *d <= reach).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The grid column at page x `x` (the first or the last beyond the table).
|
||||
fn column_at(geom: &ResizeTarget, x: f32) -> usize {
|
||||
let n = geom.cols.len().saturating_sub(1);
|
||||
geom.cols.iter().skip(1).take_while(|cx| **cx <= x).count().min(n.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The drawn row at page y `y` (the first or the last beyond the table).
|
||||
fn row_at(geom: &ResizeTarget, y: f32) -> usize {
|
||||
geom.rows.iter().find(|r| y < r.bottom).or(geom.rows.last()).map_or(0, |r| r.row)
|
||||
}
|
||||
|
||||
/// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection
|
||||
/// areas. `cells` are its cell boxes on the page.
|
||||
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option<Spot> {
|
||||
if on_handle(geom, x, y, reach) {
|
||||
return Some(Spot::Handle);
|
||||
}
|
||||
let (left, right, top, bottom) = (geom.left(), geom.right(), geom.top(), geom.bottom());
|
||||
// Above the table: the insert buttons over its column borders, and the column areas.
|
||||
if y >= top - INSERT_OUT && y <= top + COLUMN_BELOW && x >= left - INSERT_REACH && x <= right + INSERT_REACH {
|
||||
let lines = geom.cols.iter().copied().enumerate().filter(|(i, _)| geom.rows.iter().any(|r| r.edges.contains(i)));
|
||||
if y < top
|
||||
&& let Some(line) = nearest(lines, x, INSERT_REACH)
|
||||
{
|
||||
return Some(Spot::InsertColumn(line));
|
||||
}
|
||||
if y >= top - COLUMN_ABOVE && x >= left && x <= right {
|
||||
return Some(Spot::Column(column_at(geom, x)));
|
||||
}
|
||||
}
|
||||
// Left of the table: the insert buttons beside its row borders, and the row areas.
|
||||
if x >= left - ROW_LEFT && x < left && y >= top - INSERT_REACH && y <= bottom + INSERT_REACH {
|
||||
let borders = geom.rows.iter().flat_map(|r| [(r.row, r.top), (r.row + 1, r.bottom)]);
|
||||
if let Some(line) = nearest(borders, y, INSERT_REACH) {
|
||||
return Some(Spot::InsertRow(line));
|
||||
}
|
||||
if let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom) {
|
||||
return Some(Spot::Row(r.row));
|
||||
}
|
||||
}
|
||||
// Inside a cell, along its left edge.
|
||||
let edge = CELL_EDGE.max(reach + 2.5);
|
||||
cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell))
|
||||
}
|
||||
|
||||
/// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's.
|
||||
/// None on a column or row border or the resize handle: those resize (`table_resize`). `reach`
|
||||
/// is their resize reach (points).
|
||||
pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<Target> {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let reach = clean_reach(reach);
|
||||
if table_resize::resize_target(l, doc, page, x, y, reach).is_some() {
|
||||
return None;
|
||||
}
|
||||
let boxes = table_resize::cell_boxes(l, page);
|
||||
table_resize::tables_on(l, doc, page)
|
||||
.into_iter()
|
||||
.filter(|g| g.story == story)
|
||||
.filter_map(|geom| {
|
||||
let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect();
|
||||
spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom })
|
||||
})
|
||||
.max_by_key(|t| t.geom.table.0.len())
|
||||
}
|
||||
|
||||
/// The table whose move handle shows with the pointer at (`x`, `y`): the innermost table of
|
||||
/// `story` starting on this page that the pointer is over, or on its way to the handle.
|
||||
pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<ResizeTarget> {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let pad = clean_reach(reach) / 2.0;
|
||||
table_resize::tables_on(l, doc, page)
|
||||
.into_iter()
|
||||
.filter(|g| {
|
||||
let h = handle(g);
|
||||
g.story == story && starts_here(g) && x >= h.x - pad && x <= g.right() && y >= h.y - pad && y <= g.bottom()
|
||||
})
|
||||
.max_by_key(|g| g.table.0.len())
|
||||
}
|
||||
|
||||
/// The insert button a target is, if it's one.
|
||||
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
|
||||
let g = &t.geom;
|
||||
match t.spot {
|
||||
Spot::InsertRow(line) => {
|
||||
// The bottom of the row above the border, or the top of the first row.
|
||||
let y = g.rows.iter().find(|r| r.row + 1 == line).map(|r| r.bottom).or_else(|| g.rows.iter().find(|r| r.row == line).map(|r| r.top))?;
|
||||
Some(InsertMark { center: (g.left() - BUTTON_OUT, y), from: (g.left(), y), to: (g.right(), y) })
|
||||
}
|
||||
Spot::InsertColumn(line) => {
|
||||
let x = g.cols.get(line).copied()?;
|
||||
Some(InsertMark { center: (x, g.top() - BUTTON_OUT), from: (x, g.top()), to: (x, g.bottom()) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether dragging the move handle moves the table: tables in the main text, not nested ones.
|
||||
pub fn movable(geom: &ResizeTarget) -> bool {
|
||||
geom.story == StoryRef::Body && geom.table.0.len() == 1
|
||||
}
|
||||
|
||||
/// Where a move-handle drag by (`dx`, `dy`) puts the table's top-left corner on `page`: on the
|
||||
/// page, its part there above the bottom margin.
|
||||
pub fn moved_to(l: &DocLayout, page: usize, geom: &ResizeTarget, dx: f32, dy: f32) -> (f32, f32) {
|
||||
let clean = |d: f32| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
|
||||
let (w, h) = (geom.right() - geom.left(), geom.bottom() - geom.top());
|
||||
let (page_w, body_bottom) = l.pages.get(page).map_or((0.0, 0.0), |pg| (pg.w, pg.body.bottom()));
|
||||
let x = (geom.left() + clean(dx)).clamp(0.0, (page_w - w).max(0.0));
|
||||
let y = (geom.top() + clean(dy)).clamp(0.0, (body_bottom - h).max(0.0));
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Where the selected cells are drawn (page, rect), for shading them.
|
||||
pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usize, Rect)> {
|
||||
let Some(t) = doc.table(range.story, &range.table) else { return Vec::new() };
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
|
||||
let mut out = Vec::new();
|
||||
for (pi, pg) in l.pages.iter().enumerate() {
|
||||
for it in &pg.items {
|
||||
// A vertically merged cell's box spans its rows (#640); its continuation rows'
|
||||
// `covered` boxes would shade it twice.
|
||||
if let Placed::Cell { rect, table, row, cell, story, covered: false } = it
|
||||
&& *story == range.story
|
||||
&& *table == range.table
|
||||
&& picked.contains(&(*row, *cell))
|
||||
{
|
||||
out.push((pi, *rect));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The click: `page`, `x`, `y` and the resize `reach`.
|
||||
fn point(v: &Value) -> Result<(usize, f32, f32, f32), CmdError> {
|
||||
let page =
|
||||
p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?;
|
||||
Ok((page, p::req_f32(v, "x")?, p::req_f32(v, "y")?, p::f32(v, "reach").unwrap_or(REACH)))
|
||||
}
|
||||
|
||||
/// The spot under the click, in the caret's story.
|
||||
fn target(s: &mut Session, page: usize, x: f32, y: f32, reach: f32) -> Result<Target, CmdError> {
|
||||
let l = s.layout();
|
||||
if l.pages.get(page).is_none() {
|
||||
return Err(CmdError::Params(format!("no page {page}")));
|
||||
}
|
||||
target_at(&l, &s.doc, s.sel.focus.story, page, x, y, reach)
|
||||
.ok_or_else(|| CmdError::Failed("there's no table selection area or button there".into()))
|
||||
}
|
||||
|
||||
fn select_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
let to = p::f32(v, "toX").zip(p::f32(v, "toY"));
|
||||
let story = geom.story;
|
||||
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
|
||||
let (rows, cols) = match spot {
|
||||
Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))),
|
||||
Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)),
|
||||
Spot::Cell(r, c) => {
|
||||
let (g0, g1) = table::grid_cols(t, r, c);
|
||||
match to {
|
||||
Some((tx, ty)) => {
|
||||
let g = column_at(&geom, tx);
|
||||
((r, row_at(&geom, ty)), (g0.min(g), g1.max(g)))
|
||||
}
|
||||
None => ((r, r), (g0, g1)),
|
||||
}
|
||||
}
|
||||
Spot::Handle => ((0, last_row), (0, last_col)),
|
||||
Spot::InsertRow(_) | Spot::InsertColumn(_) => return Err(CmdError::Failed("there's an insert button there, not a selection area".into())),
|
||||
};
|
||||
table::select_range(s, story, &geom.table, rows, cols)
|
||||
}
|
||||
|
||||
/// A cell to insert a column beside at grid line `line`: one starting there (the column goes
|
||||
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
|
||||
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
|
||||
t.rows.iter().enumerate().find_map(|(r, row)| {
|
||||
let mut g = 0usize;
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if g == line {
|
||||
return Some((r, c, false));
|
||||
}
|
||||
g = g.saturating_add(cell.span());
|
||||
if g == line && c + 1 == row.cells.len() {
|
||||
return Some((r, c, true));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
let story = geom.story;
|
||||
// The row or column goes beside a cell at the border, as Insert Above / Below / Left / Right
|
||||
// would put it there, so it takes the border's index (`line`); then, as in Word, it's selected.
|
||||
let (r, c, after, rows, line) = match spot {
|
||||
Spot::InsertRow(line) => match line.checked_sub(1) {
|
||||
Some(above) => (above, 0, true, true, line),
|
||||
None => (0, 0, false, true, line),
|
||||
},
|
||||
Spot::InsertColumn(line) => {
|
||||
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let (r, c, after) = beside_line(t, line).ok_or_else(|| CmdError::Failed("there's no column border there".into()))?;
|
||||
(r, c, after, false, line)
|
||||
}
|
||||
_ => return Err(CmdError::Failed("there's a selection area there, not an insert button".into())),
|
||||
};
|
||||
let size = |s: &Session| s.doc.table(story, &geom.table).map(|t| (t.rows.len(), t.cols()));
|
||||
let before = size(s);
|
||||
let mut path = geom.table.0.clone();
|
||||
path.extend([r as u32, c as u32, 0]);
|
||||
s.column = None;
|
||||
s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 });
|
||||
if rows {
|
||||
table::row(s, after)?;
|
||||
} else {
|
||||
table::col(s, after)?;
|
||||
}
|
||||
if size(s) == before {
|
||||
return Err(CmdError::Failed("the table can't take another row or column".into()));
|
||||
}
|
||||
let (sel_rows, sel_cols) = if rows { ((line, line), (0, usize::MAX)) } else { ((0, usize::MAX), (line, line)) };
|
||||
table::select_range(s, story, &geom.table, sel_rows, sel_cols)
|
||||
}
|
||||
|
||||
/// Where table `table` of `story` is first drawn: its page and top-left corner.
|
||||
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
|
||||
l.pages.iter().enumerate().find_map(|(pi, pg)| {
|
||||
let cells = pg.items.iter().filter_map(|it| match it {
|
||||
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
cells.fold(None, |acc: Option<(f32, f32)>, r| Some(acc.map_or((r.x, r.y), |(x, y)| (x.min(r.x), y.min(r.y))))).map(|(x, y)| (pi, x, y))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_drag(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?);
|
||||
let Target { spot, geom } = target(s, page, x, y, reach)?;
|
||||
if spot != Spot::Handle {
|
||||
return Err(CmdError::Failed("there's no table move handle there".into()));
|
||||
}
|
||||
if !movable(&geom) {
|
||||
return Err(CmdError::Failed("only a table in the main text can be moved".into()));
|
||||
}
|
||||
let (gx, gy) = moved_to(&s.layout(), page, &geom, to_x - x, to_y - y);
|
||||
if (gx - geom.left()).abs() < 0.01 && (gy - geom.top()).abs() < 0.01 {
|
||||
return sel_result(s);
|
||||
}
|
||||
// Dragged, a table floats where it's dropped, the text wrapping around it (Word): placed from
|
||||
// its column and the text where it stands, 0.125" from the text at its sides.
|
||||
let t = s.doc.table_mut(geom.story, &geom.table)?;
|
||||
let mut f = t.props.float.unwrap_or(TableFloat {
|
||||
h_rel: Anchor::Column,
|
||||
v_rel: Anchor::Paragraph,
|
||||
dist: [9.0, 0.0, 9.0, 0.0],
|
||||
overlap: true,
|
||||
..Default::default()
|
||||
});
|
||||
f.h_align = None;
|
||||
f.v_align = None;
|
||||
t.props.float = Some(f);
|
||||
table_resize::touch(s, &geom)?;
|
||||
// Placed by offsets, the table moves as much as they do: one correction from where it landed
|
||||
// puts it where it was dropped.
|
||||
if let Some((p0, x0, y0)) = corner(&s.layout(), geom.story, &geom.table)
|
||||
&& p0 == page
|
||||
{
|
||||
let fix = |v: f32| if v.is_finite() { v.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
|
||||
let t = s.doc.table_mut(geom.story, &geom.table)?;
|
||||
if let Some(f) = t.props.float.as_mut() {
|
||||
f.x = fix(f.x + gx - x0);
|
||||
f.y = fix(f.y + gy - y0);
|
||||
}
|
||||
table_resize::touch(s, &geom)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn fit_column(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (page, x, y, reach) = point(v)?;
|
||||
let l = s.layout();
|
||||
let Some((target, line)) = table_resize::resize_target(&l, &s.doc, page, x, y, reach).and_then(|t| match t.edge {
|
||||
Edge::Column(line) => Some((t, line)),
|
||||
_ => None,
|
||||
}) else {
|
||||
return Err(CmdError::Failed("there's no column border there".into()));
|
||||
};
|
||||
let t = s.doc.table(target.story, &target.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
|
||||
let g = line.checked_sub(1).ok_or_else(|| CmdError::Failed("there's no column left of that border".into()))?;
|
||||
let (narrowest, widest) =
|
||||
wordcraft_layout::measure_table_columns(&s.doc, t).get(g).copied().ok_or_else(|| CmdError::Failed("there's no such column".into()))?;
|
||||
let current = target.widths().get(g).copied().unwrap_or(0.0);
|
||||
// As in Word, the columns right of it keep their widths and move with the border, so the
|
||||
// table narrows or widens: up to the right margin, never below the column's narrowest content.
|
||||
let room = current + l.pages.get(page).map_or(0.0, |pg| (pg.body.right() - target.right()).max(0.0));
|
||||
let want = widest.min(room.max(narrowest));
|
||||
if let Some(w) = target.plan(want - current, 0.0, true).widths {
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
table_resize::set_widths(t, w);
|
||||
table_resize::touch(s, &target)?;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::props::Align;
|
||||
|
||||
fn table_session(rows: usize, cols: usize) -> Session {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("insert.table", &json!({"rows": rows, "cols": cols})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn table_path(s: &Session) -> Path {
|
||||
Path(vec![s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32])
|
||||
}
|
||||
|
||||
fn first_table(s: &Session) -> Table {
|
||||
s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn cell_pos(s: &Session, r: usize, c: usize) -> Pos {
|
||||
let mut path = table_path(s).0;
|
||||
path.extend([r as u32, c as u32, 0]);
|
||||
Pos { story: StoryRef::Body, path: Path(path), off: 0 }
|
||||
}
|
||||
|
||||
/// Type "r{row}c{cell}" into every cell.
|
||||
fn fill(s: &mut Session) {
|
||||
let t = first_table(s);
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for c in 0..row.cells.len() {
|
||||
s.sel = Selection::caret(cell_pos(s, r, c));
|
||||
s.run("text.insert", &json!({"text": format!("r{r}c{c}")})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn para(s: &Session, r: usize, c: usize) -> wordcraft_doc::Paragraph {
|
||||
s.doc.para(StoryRef::Body, &cell_pos(s, r, c).path).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn bold(s: &Session, r: usize, c: usize) -> bool {
|
||||
let p = para(s, r, c);
|
||||
if p.is_empty() { p.mark.bold == Some(true) } else { p.run_ranges().all(|(_, ch)| ch.bold == Some(true)) }
|
||||
}
|
||||
|
||||
fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect {
|
||||
s.layout().pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| match it {
|
||||
Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn spot_at(s: &mut Session, x: f32, y: f32) -> Option<Spot> {
|
||||
let l = s.layout();
|
||||
target_at(&l, &s.doc, StoryRef::Body, 0, x, y, REACH).map(|t| t.spot)
|
||||
}
|
||||
|
||||
fn mid_y(r: Rect) -> f32 {
|
||||
r.y + r.h / 2.0
|
||||
}
|
||||
|
||||
/// The areas around and in a table: column, row and cell selection areas, the insert buttons
|
||||
/// and the move handle; borders stay the resize module's, the text the caret's.
|
||||
#[test]
|
||||
fn the_areas_of_a_table_are_found() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
let (left, top) = (a.x, a.y);
|
||||
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top - 3.0), Some(Spot::Column(1)));
|
||||
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top + 1.0), Some(Spot::Column(1)));
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, mid_y(r1)), Some(Spot::Row(1)));
|
||||
let c = cell_rect(&mut s, 1, 2);
|
||||
assert_eq!(spot_at(&mut s, c.x + 4.5, mid_y(c)), Some(Spot::Cell(1, 2)));
|
||||
assert_eq!(spot_at(&mut s, r1.x + 2.0, mid_y(r1)), Some(Spot::Cell(1, 0)), "the first column's edge has no resize border");
|
||||
assert_eq!(spot_at(&mut s, c.x + 20.0, mid_y(c)), None, "the text is the caret's");
|
||||
assert_eq!(spot_at(&mut s, b.x, mid_y(a)), None, "a column border resizes");
|
||||
assert_eq!(spot_at(&mut s, b.x, top - 8.0), Some(Spot::InsertColumn(1)));
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, a.bottom()), Some(Spot::InsertRow(1)));
|
||||
assert_eq!(spot_at(&mut s, left - 10.0, top), Some(Spot::InsertRow(0)));
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
assert_eq!(spot_at(&mut s, h.x + h.w / 2.0, h.y + h.h / 2.0), Some(Spot::Handle));
|
||||
// Another story's tables aren't in reach.
|
||||
let l = s.layout();
|
||||
assert!(target_at(&l, &s.doc, StoryRef::Part(7), 0, b.x + b.w / 2.0, top - 3.0, REACH).is_none());
|
||||
}
|
||||
|
||||
/// A selected column's text is its own cells', so the status bar and Word Count count only
|
||||
/// them, not the cells between its ends in the text.
|
||||
#[test]
|
||||
fn a_selected_columns_text_is_its_cells() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
|
||||
assert_eq!(s.selected_text(), "r0c1\nr1c1\nr2c1");
|
||||
assert_eq!(s.run("review.wordCount", &json!({})).unwrap()["words"], 3);
|
||||
}
|
||||
|
||||
/// A click above a column selects just its cells: formatting, alignment and Delete leave the
|
||||
/// cells between its ends alone, each one undo step; table commands take the column.
|
||||
#[test]
|
||||
fn a_selected_column_is_edited_cell_by_cell() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
|
||||
assert_eq!(s.column_segments().unwrap().len(), 3);
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
for r in 0..3 {
|
||||
assert!(bold(&s, r, 1), "row {r} of the column is bold");
|
||||
assert!(!bold(&s, r, 0) && !bold(&s, r, 2), "row {r}'s other cells aren't");
|
||||
}
|
||||
s.run("para.alignCenter", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 1, 1).props.align, Some(Align::Center));
|
||||
assert_eq!(para(&s, 0, 2).props.align, None, "the cells between the corners keep theirs");
|
||||
assert!(s.cell_range().is_some(), "formatting keeps the selection");
|
||||
s.run("table.shading", &json!({"color": "FF0000"})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert!(t.rows.iter().all(|row| row.cells[1].props.shading.is_some() && row.cells[0].props.shading.is_none()));
|
||||
let depth = s.undo_depth();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
for r in 0..3 {
|
||||
assert!(para(&s, r, 1).is_empty());
|
||||
assert_eq!(para(&s, r, 0).plain_text(), format!("r{r}c0"));
|
||||
assert_eq!(para(&s, r, 2).plain_text(), format!("r{r}c2"));
|
||||
}
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 2, 1).plain_text(), "r2c1");
|
||||
assert_eq!(s.cell_range().map(|r| r.cols), Some((1, 1)), "undo brings the column selection back");
|
||||
}
|
||||
|
||||
/// Numbering a column numbers its cells as one list, like Word.
|
||||
#[test]
|
||||
fn numbering_a_column_makes_one_list() {
|
||||
let mut s = table_session(3, 2);
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 1, 0));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("para.numbering", &json!({})).unwrap();
|
||||
let nums: Vec<_> = (0..3).map(|r| para(&s, r, 0).props.numbering.map(|n| n.num)).collect();
|
||||
assert!(nums[0].is_some_and(|n| n != 0) && nums.iter().all(|n| *n == nums[0]), "{nums:?}");
|
||||
assert!((0..3).all(|r| para(&s, r, 1).props.numbering.is_none()));
|
||||
}
|
||||
|
||||
/// An empty column's cells take character formatting in their marks; Tab moves on without
|
||||
/// clearing a selected column; a click in place ends a selected empty cell.
|
||||
#[test]
|
||||
fn empty_cells_tab_and_clicks() {
|
||||
let mut s = table_session(2, 2);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
assert!(bold(&s, 0, 0) && bold(&s, 1, 0) && !bold(&s, 0, 1) && !bold(&s, 1, 1));
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 1));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("text.tab", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
|
||||
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
|
||||
// A selected empty cell's corners are one place; clicking there ends the selection.
|
||||
let mut s = table_session(2, 2);
|
||||
let c = cell_rect(&mut s, 1, 1);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": c.x + 4.5, "y": mid_y(c)})).unwrap();
|
||||
assert!(s.cell_range().is_some() && s.sel.is_collapsed());
|
||||
let at = serde_json::to_value(s.sel.focus.clone()).unwrap();
|
||||
s.run("caret.set", &json!({"pos": at})).unwrap();
|
||||
assert!(s.cell_range().is_none());
|
||||
}
|
||||
|
||||
/// Rows, cells and drags: whole rows and cells of one row are an ordinary stream selection;
|
||||
/// a drag over cells of several rows selects their rectangle only.
|
||||
#[test]
|
||||
fn rows_cells_and_drags_select_rectangles() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let r1 = cell_rect(&mut s, 1, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1)})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((1, 1), (0, 2), true));
|
||||
assert!(s.column_segments().is_none(), "a row is one run of the text");
|
||||
let r2 = cell_rect(&mut s, 2, 0);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1), "toX": r1.x - 10.0, "toY": mid_y(r2) + 50.0})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
|
||||
// From cell (0, 0) to cell (1, 1): four cells, not the row between.
|
||||
let (a, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 1));
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": a.x + 2.0, "y": mid_y(a), "toX": d.x + d.w / 2.0, "toY": mid_y(d)})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 1), (0, 1), false));
|
||||
s.run("format.italic", &json!({})).unwrap();
|
||||
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
|
||||
assert!(italic(&s, 0, 0) && italic(&s, 1, 1) && !italic(&s, 0, 2) && !italic(&s, 1, 2));
|
||||
// Dragging across columns: from the first to the last is every column, all rows.
|
||||
let (c0, c2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": c0.x + 20.0, "y": c0.y - 3.0, "toX": c2.right() + 30.0, "toY": c0.y})).unwrap();
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (0, 2), true));
|
||||
// The move handle selects the table.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
s.run("table.selectAt", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 2), (0, 2))));
|
||||
// The ribbon's Select Row is the same whole-cell selection.
|
||||
s.sel = Selection::caret(cell_pos(&s, 2, 1));
|
||||
s.run("table.selectRow", &json!({})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
|
||||
}
|
||||
|
||||
/// The insert buttons add a row or column at their border, one undo step, and select it (as
|
||||
/// Word does); typing then goes into its first cell.
|
||||
#[test]
|
||||
fn the_insert_buttons_add_rows_and_columns() {
|
||||
let mut s = table_session(2, 3);
|
||||
fill(&mut s);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(para(&s, 1, 0).is_empty() && para(&s, 2, 0).plain_text() == "r1c0", "the new row is the second");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 1), (0, 2))), "it's selected");
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(para(&s, 1, 0).plain_text(), "x");
|
||||
assert_eq!((first_table(&s).rows.len(), para(&s, 2, 0).plain_text()), (3, "r1c0".to_string()));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s).rows.len(), 2);
|
||||
// Above the first row.
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.y})).unwrap();
|
||||
assert!(para(&s, 0, 0).is_empty() && para(&s, 1, 0).plain_text() == "r0c0");
|
||||
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 0)));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// A column at the border between the first two, the table keeping its width.
|
||||
let width: f32 = first_table(&s).grid.iter().sum();
|
||||
let b = cell_rect(&mut s, 0, 1);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": b.x, "y": a.y - 8.0})).unwrap();
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.cols(), 4);
|
||||
assert!(para(&s, 0, 1).is_empty() && para(&s, 0, 2).plain_text() == "r0c1");
|
||||
assert!((t.grid.iter().sum::<f32>() - width).abs() < 0.5);
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (1, 1))), "it's selected");
|
||||
// And at the right edge.
|
||||
let e = cell_rect(&mut s, 0, 3);
|
||||
s.run("table.insertAt", &json!({"page": 0, "x": e.right(), "y": a.y - 8.0})).unwrap();
|
||||
assert_eq!(first_table(&s).cols(), 5);
|
||||
assert!(para(&s, 0, 4).is_empty() && para(&s, 0, 3).plain_text() == "r0c2");
|
||||
assert_eq!(s.cell_range().map(|r| r.cols), Some((4, 4)));
|
||||
}
|
||||
|
||||
/// Dragging the move handle floats the table where it's dropped; one undo puts it back; a
|
||||
/// click on the handle moves nothing.
|
||||
#[test]
|
||||
fn dragging_the_handle_moves_the_table() {
|
||||
let mut s = table_session(2, 2);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx, "toY": hy})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_none(), "a click doesn't move it");
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 50.0, "toY": hy + 30.0})).unwrap();
|
||||
let f = first_table(&s).props.float.unwrap();
|
||||
assert_eq!((f.h_rel, f.v_rel, f.overlap), (Anchor::Column, Anchor::Paragraph, true));
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!((moved.x - (a.x + 50.0)).abs() < 0.5 && (moved.y - (a.y + 30.0)).abs() < 0.5, "{a:?} → {moved:?}");
|
||||
// Moving it again starts from where it floats.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx - 20.0, "toY": hy + 10.0})).unwrap();
|
||||
let again = cell_rect(&mut s, 0, 0);
|
||||
assert!((again.x - (moved.x - 20.0)).abs() < 0.5 && (again.y - (moved.y + 10.0)).abs() < 0.5, "{moved:?} → {again:?}");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(first_table(&s).props.float.is_none());
|
||||
assert_eq!(cell_rect(&mut s, 0, 0), a);
|
||||
// Nested tables and tables outside the main text stay where they are.
|
||||
let mut nested = table_resize::tables_on(&s.layout(), &s.doc, 0)[0].clone();
|
||||
assert!(movable(&nested));
|
||||
nested.table = Path(vec![0, 0, 0, 1]);
|
||||
assert!(!movable(&nested));
|
||||
}
|
||||
|
||||
/// A double-click on a column border fits the column left of it to its text; as in Word, the
|
||||
/// columns right of it keep their widths and move along, so the table narrows. The right
|
||||
/// edge's column fits the same way.
|
||||
#[test]
|
||||
fn a_double_click_on_a_border_fits_the_column() {
|
||||
let mut s = table_session(1, 3);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("text.insert", &json!({"text": "ab"})).unwrap();
|
||||
let (a, b, c) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
|
||||
let fit = wordcraft_layout::measure_table_columns(&s.doc, &first_table(&s));
|
||||
s.run("table.fitColumn", &json!({"page": 0, "x": a.right(), "y": mid_y(a)})).unwrap();
|
||||
let (a1, b1, c1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
|
||||
assert!((a1.w - fit[0].1).abs() < 0.5 && a1.w < a.w - 50.0, "{a:?} → {a1:?}, {fit:?}");
|
||||
assert!((b1.w - b.w).abs() < 0.5 && (c1.w - c.w).abs() < 0.5, "the columns right of it keep their widths");
|
||||
assert!((b1.x - b.x - (a1.w - a.w)).abs() < 0.5 && (c1.right() - c.right() - (a1.w - a.w)).abs() < 0.5, "and move along");
|
||||
s.run("table.fitColumn", &json!({"page": 0, "x": c1.right(), "y": mid_y(c1)})).unwrap();
|
||||
let c2 = cell_rect(&mut s, 0, 2);
|
||||
assert!((c2.w - fit[2].1).abs() < 0.5 && c2.w < c.w, "{c:?} → {c2:?}");
|
||||
}
|
||||
|
||||
/// Pasting formatting onto a selected column leaves its text (it used to clear it).
|
||||
#[test]
|
||||
fn the_format_painter_keeps_a_selected_columns_text() {
|
||||
let mut s = table_session(2, 2);
|
||||
fill(&mut s);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("edit.formatPainter", &json!({})).unwrap();
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 1));
|
||||
s.run("table.selectColumn", &json!({})).unwrap();
|
||||
s.run("edit.pasteFormat", &json!({})).unwrap();
|
||||
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
|
||||
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
|
||||
}
|
||||
|
||||
/// Where the app extends the selection while dragging (`caret.set` with `extend`).
|
||||
fn drag_to(s: &mut Session, to: Pos) {
|
||||
s.run("caret.set", &json!({"pos": to, "extend": true})).unwrap();
|
||||
}
|
||||
|
||||
/// An ordinary drag from one cell into another selects the rectangle of whole cells between
|
||||
/// them, so bold takes just those, as in Word; back in the first cell it's text again, from
|
||||
/// where the drag started. Out of a nested table, the cells are the table's around it.
|
||||
#[test]
|
||||
fn a_drag_across_cells_selects_their_rectangle() {
|
||||
let mut s = table_session(3, 3);
|
||||
fill(&mut s);
|
||||
let start = Pos { off: 1, ..cell_pos(&s, 0, 1) };
|
||||
s.run("caret.set", &json!({"pos": start})).unwrap();
|
||||
let down = Pos { off: 2, ..cell_pos(&s, 2, 1) };
|
||||
drag_to(&mut s, down);
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
|
||||
s.run("format.bold", &json!({})).unwrap();
|
||||
for r in 0..3 {
|
||||
assert!(bold(&s, r, 1) && !bold(&s, r, 0) && !bold(&s, r, 2), "row {r}: just the dragged column");
|
||||
}
|
||||
// On to the left: the rectangle still runs from the cell the drag started in.
|
||||
let left = cell_pos(&s, 1, 0);
|
||||
drag_to(&mut s, left);
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
|
||||
// Back in that cell: its text, from where the drag started.
|
||||
let back = Pos { off: 3, ..cell_pos(&s, 0, 1) };
|
||||
drag_to(&mut s, back.clone());
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel, Selection { anchor: start, focus: back });
|
||||
// From a nested table's cell into another cell of the table around it.
|
||||
let mut s = table_session(2, 2);
|
||||
s.sel = Selection::caret(cell_pos(&s, 0, 0));
|
||||
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
assert_eq!(s.sel.focus.path.0.len(), 7, "the caret is in the nested table");
|
||||
let to = cell_pos(&s, 1, 1);
|
||||
drag_to(&mut s, to);
|
||||
let range = s.cell_range().cloned().unwrap();
|
||||
assert_eq!((range.table, range.rows, range.cols), (table_path(&s), (0, 1), (0, 1)));
|
||||
}
|
||||
|
||||
/// Shift+Down from a cell takes it and the cell below, whole; on past the table, the anchor's
|
||||
/// row and every row after it, then the text; Shift+Up comes back to the cells, then into the
|
||||
/// first cell's text. Shift+click goes on from where the selection started, and from text
|
||||
/// before the table a drag into a cell takes the rows down to it, whole.
|
||||
#[test]
|
||||
fn shift_arrows_and_clicks_select_cells_then_rows() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("text.insert", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap();
|
||||
fill(&mut s);
|
||||
let after = table_path(&s).last() + 1;
|
||||
let shift = |s: &mut Session, id: &str| {
|
||||
s.run(id, &json!({"extend": true})).unwrap();
|
||||
};
|
||||
s.sel = Selection::caret(cell_pos(&s, 1, 1));
|
||||
shift(&mut s, "caret.down");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
|
||||
shift(&mut s, "caret.down");
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 0), "from the start of the anchor's row");
|
||||
assert_eq!(s.sel.focus.path, Path::top(after));
|
||||
s.run("format.italic", &json!({})).unwrap();
|
||||
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
|
||||
assert!(italic(&s, 1, 0) && italic(&s, 2, 2) && !italic(&s, 0, 1), "whole rows from the anchor's");
|
||||
shift(&mut s, "caret.up");
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
|
||||
shift(&mut s, "caret.up");
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 1));
|
||||
assert_eq!(s.sel.focus.path, cell_pos(&s, 1, 1).path);
|
||||
// Shift+click: the rectangle from the anchor's cell.
|
||||
let corner = cell_pos(&s, 0, 0);
|
||||
s.run("caret.set", &json!({"pos": corner, "extend": true})).unwrap();
|
||||
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
|
||||
// From the text before the table into a cell: the rows down to that cell's, whole.
|
||||
s.sel = Selection::caret(Pos::body(0, 2));
|
||||
let into = cell_pos(&s, 1, 1);
|
||||
drag_to(&mut s, into);
|
||||
assert!(s.cell_range().is_none());
|
||||
assert_eq!(s.sel, Selection { anchor: Pos::body(0, 2), focus: Pos { off: 4, ..cell_pos(&s, 1, 2) } });
|
||||
}
|
||||
|
||||
/// Clicks away from the areas, and hostile numbers: an error and no change, never a panic.
|
||||
#[test]
|
||||
fn misses_and_hostile_params_change_nothing() {
|
||||
let mut s = table_session(2, 2);
|
||||
let start = first_table(&s);
|
||||
let c = cell_rect(&mut s, 1, 1);
|
||||
let mid = json!({"page": 0, "x": c.x + c.w / 2.0, "y": mid_y(c), "toX": 1, "toY": 1});
|
||||
for id in ["table.selectAt", "table.insertAt", "table.moveDrag", "table.fitColumn"] {
|
||||
assert!(s.run(id, &mid).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({"page": 99, "x": 1, "y": 1, "toX": 1, "toY": 1})).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({"page": 0, "x": 1e30, "y": -1e30, "toX": 1e30, "toY": 1e30, "reach": 1e30})).is_err(), "{id}");
|
||||
assert!(s.run(id, &json!({})).is_err(), "{id}");
|
||||
}
|
||||
// An insert button isn't a selection area, nor a selection area a button.
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
assert!(s.run("table.selectAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).is_err());
|
||||
assert!(s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": mid_y(a)})).is_err());
|
||||
assert_eq!(first_table(&s), start);
|
||||
// A drag to the ends of the earth keeps the table on its page.
|
||||
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
|
||||
s.run("table.moveDrag", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0, "toX": 3e38, "toY": -3e38})).unwrap();
|
||||
let f = first_table(&s).props.float.unwrap();
|
||||
assert!(f.x.is_finite() && f.y.is_finite());
|
||||
let moved = cell_rect(&mut s, 0, 0);
|
||||
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
//! Resizing a table with the mouse, like Word: drag a border between columns (or the table's
|
||||
//! right edge), a row's bottom border, or the small handle just outside the table's bottom-right
|
||||
//! corner to scale the whole table. The geometry comes from the layout (what is on the page, in
|
||||
//! left or right edge), a row's bottom border, or the small handle just outside the table's
|
||||
//! bottom-right corner to scale the whole table. With cells selected, a border of theirs moves
|
||||
//! only in their rows. The geometry comes from the layout (what is on the page, in
|
||||
//! page points), so the result is what the drag showed even when autofit or a percentage width
|
||||
//! made the drawn columns differ from the stored grid. The app sends each finished drag here,
|
||||
//! so every drag is one undo step.
|
||||
|
||||
use serde_json::Value;
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
use wordcraft_doc::{Document, Path, StoryRef, Table};
|
||||
use wordcraft_doc::props::{Align, HeightRule, VMerge};
|
||||
use wordcraft_doc::table::MAX_COLS;
|
||||
use wordcraft_doc::{Cell, Document, Path, StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
use wordcraft_layout::{DocLayout, Placed};
|
||||
|
||||
|
|
@ -27,15 +29,15 @@ const MAX_LEN: f32 = 1584.0;
|
|||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec::new("table.resizeDrag", "Resize Table by Dragging", "Table Layout › Cell Size", resize_drag).params(
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "shift"?: bool, "reach"?: pt} (a mouse drag in page points, 0-based page, starting on a table border or the resize handle: a column border moves (the column to its right gives or takes the difference, so the table keeps its width; with shift, or on the table's right edge, only the column left of it changes); a row's bottom border sets that row's height; the handle just outside the bottom-right corner scales every column and the rows on that page. `reach` (default 3) is how near the start has to be.)"#,
|
||||
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "shift"?: bool, "ctrl"?: bool, "reach"?: pt} (a mouse drag in page points, 0-based page, starting on a table border or the resize handle: a column border moves (the column to its right gives or takes the difference, so the table keeps its width; with ctrl the columns right of it share it in proportion; with shift, or on the table's right edge, only the column left of it changes); the table's left edge moves a left-aligned table's indent, the first column making up the difference (a centred or right-aligned table keeps its alignment: only the first column changes); with cells of the table selected, a border of theirs moves only in their rows; a row's bottom border sets that row's height; the handle just outside the bottom-right corner scales every column and the rows on that page. `reach` (default 3) is how near the start has to be.)"#,
|
||||
)]
|
||||
}
|
||||
|
||||
/// What a press on the page would grab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
/// The vertical border at grid line `line` (1 = between the first two columns; the last is
|
||||
/// the table's right edge).
|
||||
/// The vertical border at grid line `line` (0 = the table's left edge, 1 = between the first
|
||||
/// two columns; the last is the table's right edge).
|
||||
Column(usize),
|
||||
/// The bottom border of row `row`.
|
||||
Row(usize),
|
||||
|
|
@ -43,6 +45,27 @@ pub enum Edge {
|
|||
Corner,
|
||||
}
|
||||
|
||||
/// Who makes up the difference when a column border moves: Word's keys while dragging.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Share {
|
||||
/// No key: the column on the right, so the table keeps its width.
|
||||
#[default]
|
||||
Neighbour,
|
||||
/// Shift (and a ruler marker): nobody; only the column on the left changes and the ones
|
||||
/// right of it move along.
|
||||
LeftOnly,
|
||||
/// Ctrl: the columns on the right, in proportion to their widths, so the table keeps its
|
||||
/// width.
|
||||
Proportional,
|
||||
}
|
||||
|
||||
/// Whether Shift is held.
|
||||
impl From<bool> for Share {
|
||||
fn from(shift: bool) -> Share {
|
||||
if shift { Share::LeftOnly } else { Share::Neighbour }
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the table as drawn on one page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawnRow {
|
||||
|
|
@ -50,7 +73,7 @@ pub struct DrawnRow {
|
|||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
/// Grid lines that are cell borders in this row (a merged cell hides the ones it covers).
|
||||
edges: Vec<usize>,
|
||||
pub(crate) edges: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A table border (or the handle) under the pointer, with the table's drawn geometry.
|
||||
|
|
@ -65,6 +88,10 @@ pub struct ResizeTarget {
|
|||
pub rows: Vec<DrawnRow>,
|
||||
/// Whether the table's last row is on this page (the handle is shown only then).
|
||||
pub ends_here: bool,
|
||||
/// When cells of the table are selected and the column border is one of theirs
|
||||
/// (`limit_to_selection`): their rows, each with the grid lines its cells' borders are on.
|
||||
/// The border moves only there; empty, it moves in the whole column.
|
||||
pub only: Vec<(usize, Vec<usize>)>,
|
||||
}
|
||||
|
||||
/// What a drag to a point would make of the table.
|
||||
|
|
@ -74,6 +101,13 @@ pub struct Plan {
|
|||
pub widths: Option<Vec<f32>>,
|
||||
/// New heights (points) of these rows.
|
||||
pub heights: Vec<(usize, f32)>,
|
||||
/// How far (points) the table's left edge moves: a left-aligned table's indent (or a
|
||||
/// floating table's offset) changes by as much; a centred or right-aligned table keeps its
|
||||
/// alignment.
|
||||
pub indent_by: Option<f32>,
|
||||
/// New cell widths (points, left to right) of the rows whose border moves on its own
|
||||
/// (`ResizeTarget::only`).
|
||||
pub cells: Vec<(usize, Vec<f32>)>,
|
||||
}
|
||||
|
||||
impl ResizeTarget {
|
||||
|
|
@ -98,35 +132,52 @@ impl ResizeTarget {
|
|||
Rect::new(self.right() + 1.0, self.bottom() + 1.0, HANDLE, HANDLE)
|
||||
}
|
||||
|
||||
/// The new widths and heights a drag that moved the pointer by (`dx`, `dy`) points gives.
|
||||
pub fn plan(&self, dx: f32, dy: f32, shift: bool) -> Plan {
|
||||
/// The new widths and heights a drag that moved the pointer by (`dx`, `dy`) points gives;
|
||||
/// `share` says who makes up a column border's move (`true`: Shift is held).
|
||||
pub fn plan(&self, dx: f32, dy: f32, share: impl Into<Share>) -> Plan {
|
||||
let share = share.into();
|
||||
let (dx, dy) =
|
||||
(if dx.is_finite() { dx.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }, if dy.is_finite() { dy.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 });
|
||||
let mut w = self.widths();
|
||||
match self.edge {
|
||||
Edge::Column(line) => {
|
||||
let Some(left) = line.checked_sub(1).and_then(|i| w.get(i).copied()) else { return Plan::default() };
|
||||
let right = w.get(line).copied();
|
||||
let (new_left, new_right) = match right {
|
||||
// An inner border: the neighbour on the right makes up the difference.
|
||||
Some(right) if !shift => {
|
||||
let d = dx.clamp(MIN_COL - left, (right - MIN_COL).max(MIN_COL - left));
|
||||
(left + d, Some(right - d))
|
||||
}
|
||||
// The right edge (or Shift): only the column on the left changes.
|
||||
_ => ((left + dx).clamp(MIN_COL, MAX_LEN), None),
|
||||
};
|
||||
if let Some(x) = w.get_mut(line - 1) {
|
||||
*x = new_left;
|
||||
}
|
||||
if let (Some(r), Some(x)) = (new_right, w.get_mut(line)) {
|
||||
*x = r;
|
||||
}
|
||||
Plan { widths: Some(w), heights: Vec::new() }
|
||||
// The table's left edge: the table moves in or out and the first column makes up the
|
||||
// difference, so the other borders stay.
|
||||
Edge::Column(0) => {
|
||||
let Some(first) = w.first_mut() else { return Plan::default() };
|
||||
let d = dx.clamp(*first - MAX_LEN, (*first - MIN_COL).max(*first - MAX_LEN));
|
||||
*first -= d;
|
||||
Plan { widths: Some(w), indent_by: Some(d), ..Plan::default() }
|
||||
}
|
||||
// A border of selected cells: it moves in their rows only, as far as all of them let
|
||||
// it, each row sharing the move out among its own cells.
|
||||
Edge::Column(line) if !self.only.is_empty() => {
|
||||
let rows: Vec<(usize, Vec<f32>, usize)> = self
|
||||
.only
|
||||
.iter()
|
||||
.filter_map(|(r, lines)| {
|
||||
let i = lines.iter().position(|g| *g == line)?.checked_sub(1)?;
|
||||
let xs: Vec<f32> = lines.iter().map(|g| self.cols.get(*g).copied()).collect::<Option<_>>()?;
|
||||
Some((*r, xs.windows(2).map(|x| (x[1] - x[0]).max(0.0)).collect(), i))
|
||||
})
|
||||
.collect();
|
||||
let d = rows.iter().filter_map(|(_, w, i)| share_out(&mut w.clone(), *i, dx, share)).min_by(|a, b| a.abs().total_cmp(&b.abs()));
|
||||
let Some(d) = d else { return Plan::default() };
|
||||
let cells = rows
|
||||
.into_iter()
|
||||
.map(|(r, mut w, i)| {
|
||||
share_out(&mut w, i, d, share);
|
||||
(r, w)
|
||||
})
|
||||
.collect();
|
||||
Plan { cells, ..Plan::default() }
|
||||
}
|
||||
Edge::Column(line) => match line.checked_sub(1).and_then(|i| share_out(&mut w, i, dx, share)) {
|
||||
Some(_) => Plan { widths: Some(w), ..Plan::default() },
|
||||
None => Plan::default(),
|
||||
},
|
||||
Edge::Row(row) => {
|
||||
let Some(r) = self.rows.iter().find(|r| r.row == row) else { return Plan::default() };
|
||||
Plan { widths: None, heights: vec![(row, (r.bottom - r.top + dy).clamp(MIN_ROW, MAX_LEN))] }
|
||||
Plan { heights: vec![(row, (r.bottom - r.top + dy).clamp(MIN_ROW, MAX_LEN))], ..Plan::default() }
|
||||
}
|
||||
Edge::Corner => {
|
||||
let (width, height) = (self.right() - self.left(), self.bottom() - self.top());
|
||||
|
|
@ -138,22 +189,131 @@ impl ResizeTarget {
|
|||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Plan { widths, heights }
|
||||
Plan { widths, heights, ..Plan::default() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page x of grid line `line` once `plan` is applied: in row `row`, when that row's border
|
||||
/// moves on its own (`Plan::cells`), else in every row.
|
||||
pub fn planned_x(&self, plan: &Plan, row: Option<usize>, line: usize) -> f32 {
|
||||
let left = self.left() + plan.indent_by.unwrap_or(0.0);
|
||||
if let Some(r) = row
|
||||
&& let Some((_, w)) = plan.cells.iter().find(|(c, _)| *c == r)
|
||||
&& let Some(k) = self.only.iter().find(|(o, _)| *o == r).and_then(|(_, lines)| lines.iter().position(|g| *g == line))
|
||||
{
|
||||
return left + w.iter().take(k).sum::<f32>();
|
||||
}
|
||||
match &plan.widths {
|
||||
Some(w) => left + w.iter().take(line).sum::<f32>(),
|
||||
None => self.cols.get(line).copied().unwrap_or(left),
|
||||
}
|
||||
}
|
||||
|
||||
/// When the selection runs across cells of this table (Word selects them whole) and the
|
||||
/// column border grabbed at page y `y` is one of theirs, in one of their rows, the drag
|
||||
/// moves it only in those rows (`only`), as Word does with selected cells' borders. Not the
|
||||
/// table's left edge, which moves the whole table, nor a border of a cell merged down into
|
||||
/// rows outside the selection. Text selected within one cell isn't a cell selection.
|
||||
pub fn limit_to_selection(&mut self, s: &Session, y: f32) {
|
||||
self.only.clear();
|
||||
let Edge::Column(line) = self.edge else { return };
|
||||
let (a, b) = s.sel.ordered();
|
||||
if line == 0 || s.sel.is_collapsed() || a.story != self.story || b.story != self.story {
|
||||
return;
|
||||
}
|
||||
let (Some(t), Some((ra, ca)), Some((rb, cb))) =
|
||||
(s.doc.table(self.story, &self.table), cell_in(&self.table, &a.path), cell_in(&self.table, &b.path))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if (ra, ca) == (rb, cb) {
|
||||
return;
|
||||
}
|
||||
let span = |r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span);
|
||||
let (ga, gb) = (t.grid_col(ra, ca), t.grid_col(rb, cb));
|
||||
let (lo, hi) = (ga.min(gb), (ga + span(ra, ca)).max(gb + span(rb, cb)));
|
||||
let (r0, r1) = (ra.min(rb), ra.max(rb));
|
||||
let here = self.rows.iter().find(|r| y >= r.top && y <= r.bottom).map(|r| r.row);
|
||||
if line < lo || line > hi || !here.is_some_and(|r| (r0..=r1).contains(&r)) {
|
||||
return;
|
||||
}
|
||||
let mut only = Vec::new();
|
||||
for r in r0..=r1 {
|
||||
let Some(row) = t.rows.get(r) else { break };
|
||||
let mut lines = vec![0usize];
|
||||
for cell in &row.cells {
|
||||
let g = lines.last().copied().unwrap_or(0);
|
||||
let end = g.saturating_add(cell.span());
|
||||
// A merged cell that goes on above or below the selection keeps one width.
|
||||
let below =
|
||||
|| t.cell_at_grid(r + 1, g).and_then(|c| t.rows.get(r + 1)?.cells.get(c)).is_some_and(|c| c.props.vmerge == VMerge::Continue);
|
||||
let past = (r == r0 && cell.props.vmerge == VMerge::Continue) || (r == r1 && cell.props.vmerge != VMerge::None && below());
|
||||
if past && (g == line || end == line) {
|
||||
return;
|
||||
}
|
||||
lines.push(end);
|
||||
}
|
||||
only.push((r, lines));
|
||||
}
|
||||
self.only = only;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the border right of column `i` of `w` (column widths, left to right) by up to `dx`
|
||||
/// points, `share` saying who makes up the difference, and no column narrower than `MIN_COL`:
|
||||
/// how far it moved, or `None` when there's no column `i`.
|
||||
fn share_out(w: &mut [f32], i: usize, dx: f32, share: Share) -> Option<f32> {
|
||||
let left = *w.get(i)?;
|
||||
let rest: f32 = w.iter().skip(i + 1).sum();
|
||||
let narrowest = w.iter().skip(i + 1).copied().fold(f32::INFINITY, f32::min);
|
||||
// On the table's right edge (or with no room on the right) only the left column changes.
|
||||
let share = match share {
|
||||
Share::Neighbour if i + 1 < w.len() => Share::Neighbour,
|
||||
Share::Proportional if rest > 0.0 && narrowest > 0.0 => Share::Proportional,
|
||||
_ => Share::LeftOnly,
|
||||
};
|
||||
let least = MIN_COL - left;
|
||||
let d = match share {
|
||||
Share::Neighbour => dx.clamp(least, (w.get(i + 1).copied().unwrap_or(0.0) - MIN_COL).max(least)),
|
||||
// As far as the narrowest column on the right, shrunk in proportion, stays `MIN_COL`.
|
||||
Share::Proportional => dx.clamp(least, (rest * (1.0 - (MIN_COL / narrowest).min(1.0))).max(least)),
|
||||
Share::LeftOnly => (left + dx).clamp(MIN_COL, MAX_LEN) - left,
|
||||
};
|
||||
if let Some(x) = w.get_mut(i) {
|
||||
*x = left + d;
|
||||
}
|
||||
match share {
|
||||
Share::Neighbour => {
|
||||
if let Some(x) = w.get_mut(i + 1) {
|
||||
*x -= d;
|
||||
}
|
||||
}
|
||||
Share::Proportional => w.iter_mut().skip(i + 1).for_each(|x| *x *= (rest - d) / rest),
|
||||
Share::LeftOnly => {}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// The (row, cell) of table `tp` that a position at `p` is in.
|
||||
fn cell_in(tp: &Path, p: &Path) -> Option<(usize, usize)> {
|
||||
let n = tp.0.len();
|
||||
if p.0.len() < n + 3 || !p.0.starts_with(&tp.0) {
|
||||
return None;
|
||||
}
|
||||
Some((*p.0.get(n)? as usize, *p.0.get(n + 1)? as usize))
|
||||
}
|
||||
|
||||
/// A table cell's area on a page.
|
||||
struct CellBox {
|
||||
rect: Rect,
|
||||
table: Path,
|
||||
story: StoryRef,
|
||||
row: usize,
|
||||
cell: usize,
|
||||
pub(crate) struct CellBox {
|
||||
pub(crate) rect: Rect,
|
||||
pub(crate) table: Path,
|
||||
pub(crate) story: StoryRef,
|
||||
pub(crate) row: usize,
|
||||
pub(crate) cell: usize,
|
||||
}
|
||||
|
||||
fn cell_boxes(l: &DocLayout, page: usize) -> Vec<CellBox> {
|
||||
pub(crate) fn cell_boxes(l: &DocLayout, page: usize) -> Vec<CellBox> {
|
||||
let Some(pg) = l.pages.get(page) else { return Vec::new() };
|
||||
pg.items
|
||||
.iter()
|
||||
|
|
@ -229,7 +389,7 @@ fn geometry(t: &Table, boxes: &[&CellBox]) -> Option<(Vec<f32>, Vec<DrawnRow>)>
|
|||
}
|
||||
|
||||
/// Every table drawn on a page, with its geometry there (`edge` is a placeholder).
|
||||
fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<ResizeTarget> {
|
||||
pub(crate) fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<ResizeTarget> {
|
||||
let boxes = cell_boxes(l, page);
|
||||
let mut tables: Vec<(StoryRef, &Path)> = Vec::new();
|
||||
for b in &boxes {
|
||||
|
|
@ -244,7 +404,7 @@ fn tables_on(l: &DocLayout, doc: &Document, page: usize) -> Vec<ResizeTarget> {
|
|||
let mine: Vec<&CellBox> = boxes.iter().filter(|b| b.story == story && b.table == *path).collect();
|
||||
let (cols, rows) = geometry(t, &mine)?;
|
||||
let ends_here = rows.last().is_some_and(|r| r.row + 1 == t.rows.len());
|
||||
Some(ResizeTarget { edge: Edge::Corner, story, table: path.clone(), cols, rows, ends_here })
|
||||
Some(ResizeTarget { edge: Edge::Corner, story, table: path.clone(), cols, rows, ends_here, only: Vec::new() })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -293,8 +453,11 @@ pub fn resize_target(l: &DocLayout, doc: &Document, page: usize, x: f32, y: f32,
|
|||
// Column borders: in the row under the pointer, only lines that are cell borders there.
|
||||
let row_here = target.rows.iter().find(|r| y >= r.top && y <= r.bottom).cloned();
|
||||
if let Some(r) = &row_here {
|
||||
for line in 1..target.cols.len() {
|
||||
for line in 0..target.cols.len() {
|
||||
// The table's left edge is grabbed from outside it (a point in, for the border's
|
||||
// width): inside starts the first cell.
|
||||
if r.edges.contains(&line)
|
||||
&& (line > 0 || x <= left + 1.0)
|
||||
&& let Some(cx) = target.cols.get(line).copied()
|
||||
{
|
||||
offer((x - cx).abs(), Edge::Column(line), &mut target);
|
||||
|
|
@ -316,27 +479,23 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?;
|
||||
let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?);
|
||||
let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?);
|
||||
let shift = p::bool(v, "shift").unwrap_or(false);
|
||||
// Ctrl wins over Shift, as in Word.
|
||||
let share = if p::bool(v, "ctrl").unwrap_or(false) { Share::Proportional } else { Share::from(p::bool(v, "shift").unwrap_or(false)) };
|
||||
let reach = p::f32(v, "reach").unwrap_or(REACH);
|
||||
let l = s.layout();
|
||||
if l.pages.get(page).is_none() {
|
||||
return Err(CmdError::Params(format!("no page {page}")));
|
||||
}
|
||||
let target =
|
||||
let mut target =
|
||||
resize_target(&l, &s.doc, page, x, y, reach).ok_or_else(|| CmdError::Failed("there's no table border or resize handle there".into()))?;
|
||||
let plan = target.plan(to_x - x, to_y - y, shift);
|
||||
if plan.widths.is_none() && plan.heights.is_empty() {
|
||||
target.limit_to_selection(s, y);
|
||||
let plan = target.plan(to_x - x, to_y - y, share);
|
||||
if plan.widths.is_none() && plan.heights.is_empty() && plan.cells.is_empty() {
|
||||
return sel_result(s);
|
||||
}
|
||||
let t = s.doc.table_mut(target.story, &target.table)?;
|
||||
if let Some(widths) = plan.widths
|
||||
&& widths.len() == t.cols()
|
||||
{
|
||||
t.grid = widths;
|
||||
// The drag's widths are the table's now: no preferred table width scales them again.
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
if let Some(widths) = plan.widths {
|
||||
set_widths(t, widths);
|
||||
}
|
||||
for (r, h) in &plan.heights {
|
||||
if let Some(row) = t.rows.get_mut(*r) {
|
||||
|
|
@ -346,7 +505,21 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
}
|
||||
}
|
||||
}
|
||||
let moved = plan.indent_by.is_some_and(|d| move_left_edge(t, d));
|
||||
if !plan.cells.is_empty() {
|
||||
regrid(t, &target.cols, &plan.cells)?;
|
||||
}
|
||||
touch(s, &target)?;
|
||||
// Where a moved left edge lands isn't always its indent or offset alone (a floating table's
|
||||
// anchor): measure where it came to and put it right once.
|
||||
if moved
|
||||
&& let Some(d) = plan.indent_by
|
||||
&& let Some(got) = drawn_left(&s.layout(), page, &target)
|
||||
&& (got - (target.left() + d)).abs() > 0.05
|
||||
{
|
||||
move_left_edge(s.doc.table_mut(target.story, &target.table)?, target.left() + d - got);
|
||||
touch(s, &target)?;
|
||||
}
|
||||
if plan.heights.is_empty() {
|
||||
return sel_result(s);
|
||||
}
|
||||
|
|
@ -384,6 +557,95 @@ fn resize_drag(s: &mut Session, v: &Value) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Moves `t`'s left edge `by` points: its indent, or a floating table's offset; whether it did.
|
||||
/// A centred or right-aligned table keeps its alignment, as in Word: the first column's new width
|
||||
/// is all that changes, and the table stays centred or on its right edge.
|
||||
fn move_left_edge(t: &mut Table, by: f32) -> bool {
|
||||
match t.props.float.as_mut() {
|
||||
Some(f) => {
|
||||
f.h_align = None;
|
||||
f.x = (f.x + by).clamp(-MAX_LEN, MAX_LEN);
|
||||
true
|
||||
}
|
||||
None if matches!(t.props.align, Some(Align::Center | Align::Right)) => false,
|
||||
None => {
|
||||
t.props.indent = Some((t.props.indent.unwrap_or(0.0) + by).clamp(-MAX_LEN, MAX_LEN));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `t`'s grid when some rows' cells change width on their own: `rows` gives their new
|
||||
/// widths, left to right; the other rows keep their cells' borders where they are drawn (the
|
||||
/// grid lines at `cols`). The grid gets a line wherever a row has a cell border, and each cell
|
||||
/// spans the columns between its own.
|
||||
fn regrid(t: &mut Table, cols: &[f32], rows: &[(usize, Vec<f32>)]) -> Result<(), CmdError> {
|
||||
let left = cols.first().copied().unwrap_or(0.0);
|
||||
let last = cols.len().saturating_sub(1);
|
||||
// Every row's cell borders, measured from the table's left edge.
|
||||
let borders: Vec<Vec<f32>> = t
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| match rows.iter().find(|(i, w)| *i == r && w.len() == row.cells.len()) {
|
||||
Some((_, w)) => std::iter::once(0.0)
|
||||
.chain(w.iter().scan(0.0, |x, cw| {
|
||||
*x += cw;
|
||||
Some(*x)
|
||||
}))
|
||||
.collect::<Vec<f32>>(),
|
||||
None => std::iter::once(0.0)
|
||||
.chain(row.cells.iter().scan(0usize, |g, c| {
|
||||
*g = g.saturating_add(c.span());
|
||||
Some(cols.get((*g).min(last)).copied().unwrap_or(left) - left)
|
||||
}))
|
||||
.collect::<Vec<f32>>(),
|
||||
})
|
||||
.collect();
|
||||
let mut lines: Vec<f32> = borders.iter().flatten().copied().collect();
|
||||
lines.sort_by(f32::total_cmp);
|
||||
lines.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||||
if lines.len() > MAX_COLS + 1 {
|
||||
return Err(CmdError::Failed(format!("a table can't have more than {MAX_COLS} columns")));
|
||||
}
|
||||
let line_at = |x: f32| lines.iter().enumerate().min_by(|(_, a), (_, b)| (*a - x).abs().total_cmp(&(*b - x).abs())).map_or(0, |(i, _)| i);
|
||||
let mut spans: Vec<Vec<u32>> = Vec::with_capacity(borders.len());
|
||||
for b in &borders {
|
||||
let at: Vec<usize> = b.iter().map(|x| line_at(*x)).collect();
|
||||
let row: Vec<u32> = at.windows(2).map(|g| g[1].saturating_sub(g[0]) as u32).collect();
|
||||
if row.contains(&0) {
|
||||
return Err(CmdError::Failed("the cells there are too narrow to move that border".into()));
|
||||
}
|
||||
spans.push(row);
|
||||
}
|
||||
t.grid = lines.windows(2).map(|x| x[1] - x[0]).collect();
|
||||
for (row, row_spans) in t.rows.iter_mut().zip(spans) {
|
||||
for (cell, span) in row.cells.iter_mut().zip(row_spans) {
|
||||
cell.props.span = span;
|
||||
}
|
||||
}
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page x of the target table's left edge on `page`, as drawn.
|
||||
fn drawn_left(l: &DocLayout, page: usize, target: &ResizeTarget) -> Option<f32> {
|
||||
cell_boxes(l, page).iter().filter(|b| b.story == target.story && b.table == target.table).map(|b| b.rect.x).min_by(f32::total_cmp)
|
||||
}
|
||||
|
||||
/// Make `widths` (one per grid column) the table's column widths, as drawn: no preferred table
|
||||
/// width scales them again.
|
||||
pub(crate) fn set_widths(t: &mut Table, widths: Vec<f32>) {
|
||||
if widths.len() == t.cols() {
|
||||
t.grid = widths;
|
||||
t.props.width = None;
|
||||
t.props.width_pct = None;
|
||||
super::table::sync_cell_widths(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawn height of each of the target table's rows on `page`.
|
||||
fn drawn_heights(l: &DocLayout, doc: &Document, page: usize, target: &ResizeTarget) -> Vec<(usize, f32)> {
|
||||
tables_on(l, doc, page)
|
||||
|
|
@ -394,7 +656,7 @@ fn drawn_heights(l: &DocLayout, doc: &Document, page: usize, target: &ResizeTarg
|
|||
}
|
||||
|
||||
/// Mark the table's paragraphs changed, so the layout redoes them.
|
||||
fn touch(s: &mut Session, target: &ResizeTarget) -> Result<(), CmdError> {
|
||||
pub(crate) fn touch(s: &mut Session, target: &ResizeTarget) -> Result<(), CmdError> {
|
||||
let story = target.story;
|
||||
let paths: Vec<Path> =
|
||||
s.doc.para_paths(story).into_iter().filter(|q| q.0.len() > target.table.0.len() && q.0.starts_with(&target.table.0)).collect();
|
||||
|
|
@ -568,4 +830,128 @@ mod tests {
|
|||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start);
|
||||
}
|
||||
|
||||
/// With Ctrl the columns right of the border share the change in proportion to their
|
||||
/// widths, so the table keeps its width (Ctrl wins over Shift); none gets narrower than the
|
||||
/// minimum.
|
||||
#[test]
|
||||
fn ctrl_shares_a_border_drag_among_the_columns_right_of_it() {
|
||||
let mut s = table_session(1, 4);
|
||||
let (a, b, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 3));
|
||||
let y = a.y + a.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": y, "toX": a.right() + 30.0, "toY": y, "ctrl": true, "shift": true}))
|
||||
.unwrap();
|
||||
let a1 = cell_rect(&mut s, 0, 0);
|
||||
assert!((a1.w - (a.w + 30.0)).abs() < 1.0, "{a:?} → {a1:?}");
|
||||
for c in 1..4 {
|
||||
let w = cell_rect(&mut s, 0, c).w;
|
||||
assert!((w - (b.w - 10.0)).abs() < 1.0, "column {c}: {} → {w}", b.w);
|
||||
}
|
||||
assert!((cell_rect(&mut s, 0, 3).right() - d.right()).abs() < 1.0, "the table keeps its width");
|
||||
// In proportion: 40 and 80 give up 10 and 20 of a 30-point move.
|
||||
let mut w = [100.0, 40.0, 80.0];
|
||||
assert_eq!(share_out(&mut w, 0, 30.0, Share::Proportional), Some(30.0));
|
||||
assert!((w[1] - 30.0).abs() < 0.01 && (w[2] - 60.0).abs() < 0.01, "{w:?}");
|
||||
// As far as the narrowest stays at the minimum.
|
||||
let mut w = [100.0, 12.0, 120.0];
|
||||
share_out(&mut w, 0, 500.0, Share::Proportional);
|
||||
assert!((w[1] - MIN_COL).abs() < 0.01 && (w.iter().sum::<f32>() - 232.0).abs() < 0.01, "{w:?}");
|
||||
assert_eq!(share_out(&mut w, 3, 1.0, Share::Proportional), None);
|
||||
}
|
||||
|
||||
/// The table's left edge, grabbed from just outside it, moves the table in or out: its
|
||||
/// indent changes and the first column makes up the difference, so the other borders stay.
|
||||
/// A centred or right-aligned table keeps its alignment, as in Word: only the first column
|
||||
/// changes, and the table stays centred or on its right edge.
|
||||
#[test]
|
||||
fn the_left_edge_moves_the_indent_and_the_first_column() {
|
||||
let mut s = table_session(2, 3);
|
||||
let start = first_table(&s);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
let y = a.y + a.h / 2.0;
|
||||
let l = s.layout();
|
||||
assert_eq!(resize_target(&l, &s.doc, 0, a.x - 2.0, y, REACH).map(|t| t.edge), Some(Edge::Column(0)));
|
||||
assert!(resize_target(&l, &s.doc, 0, a.x + 2.5, y, REACH).is_none(), "inside the first cell");
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.x - 1.0, "y": y, "toX": a.x + 35.0, "toY": y})).unwrap();
|
||||
let (a1, b1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
|
||||
assert!((a1.x - (a.x + 36.0)).abs() < 0.5 && (a1.right() - a.right()).abs() < 0.5, "{a:?} → {a1:?}");
|
||||
assert!((b1.x - b.x).abs() < 0.5 && (b1.w - b.w).abs() < 0.5, "the other columns stay: {b:?} → {b1:?}");
|
||||
assert!((cell_rect(&mut s, 1, 0).x - a1.x).abs() < 0.01, "the whole table moved in");
|
||||
assert!((first_table(&s).props.indent.unwrap() - 36.0).abs() < 0.5, "{:?}", first_table(&s).props.indent);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "one undo step");
|
||||
s.run("table.properties", &json!({"align": "center", "width": 300})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
let y = c.y + c.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x - 20.0, "toY": y})).unwrap();
|
||||
let (c1, e1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
assert!((c1.w - (c.w + 20.0)).abs() < 0.5, "the first column takes the drag: {c:?} → {c1:?}");
|
||||
assert!((e1.w - e.w).abs() < 0.5, "the other columns keep their widths");
|
||||
assert!(((c1.x + e1.right()) - (c.x + e.right())).abs() < 1.0, "the table stays centred: {c:?} {e:?} → {c1:?} {e1:?}");
|
||||
assert_eq!(first_table(&s).props.align, Some(Align::Center));
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("table.properties", &json!({"align": "right"})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x - 20.0, "toY": y})).unwrap();
|
||||
let (c1, e1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
|
||||
assert!((c1.x - (c.x - 20.0)).abs() < 0.5 && (e1.right() - e.right()).abs() < 0.5, "only the left edge moves: {c:?} → {c1:?}");
|
||||
assert_eq!(first_table(&s).props.align, Some(Align::Right));
|
||||
}
|
||||
|
||||
/// With the selection across cells, a border of theirs moves only in their rows: the grid
|
||||
/// gets a line for those rows' border and the other rows keep theirs. Text within one cell
|
||||
/// isn't a cell selection; a border grabbed in a row outside the selection moves in the
|
||||
/// whole column.
|
||||
#[test]
|
||||
fn with_cells_selected_their_border_moves_only_in_their_rows() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (b, d) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 2, 1));
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Wide"})).unwrap();
|
||||
let start = first_table(&s);
|
||||
let (b, c) = (cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
|
||||
let y = b.y + b.h / 2.0;
|
||||
let drag = json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y});
|
||||
s.run("select.text", &json!({"text": "Wide"})).unwrap();
|
||||
s.run("table.resizeDrag", &drag).unwrap();
|
||||
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "text in one cell: every row");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
// From the second row's cell down into the third's.
|
||||
s.run("caret.set", &json!({"page": 0, "x": d.x + d.w / 2.0, "y": d.y + d.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.resizeDrag", &drag).unwrap();
|
||||
for r in [1, 2] {
|
||||
let (b1, c1) = (cell_rect(&mut s, r, 1), cell_rect(&mut s, r, 2));
|
||||
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "row {r}: {b1:?} {c1:?}");
|
||||
}
|
||||
assert!((cell_rect(&mut s, 0, 1).right() - b.right()).abs() < 0.5, "the first row keeps its border");
|
||||
let t = first_table(&s);
|
||||
assert_eq!(t.grid.len(), 4, "{:?}", t.grid);
|
||||
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [1, 1, 2]);
|
||||
assert_eq!(t.rows[2].cells.iter().map(Cell::span).collect::<Vec<_>>(), [1, 2, 1]);
|
||||
let a = cell_rect(&mut s, 0, 0);
|
||||
let ya = a.y + a.h / 2.0;
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": a.right(), "y": ya, "toX": a.right() - 10.0, "toY": ya})).unwrap();
|
||||
assert!((cell_rect(&mut s, 1, 0).w - (a.w - 10.0)).abs() < 1.0, "a row outside the selection: the whole column");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_table(&s), start, "one undo step each");
|
||||
}
|
||||
|
||||
/// A cell merged down past the selection keeps one width: a border of it moves in the whole
|
||||
/// column.
|
||||
#[test]
|
||||
fn a_border_of_a_cell_merged_past_the_selection_moves_in_every_row() {
|
||||
let mut s = table_session(3, 3);
|
||||
let (a, b) = (cell_rect(&mut s, 0, 1), cell_rect(&mut s, 1, 1));
|
||||
s.run("caret.set", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.y + a.h / 2.0})).unwrap();
|
||||
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.merge", &json!({})).unwrap();
|
||||
let (c, e) = (cell_rect(&mut s, 1, 2), cell_rect(&mut s, 2, 2));
|
||||
let y = c.y + c.h / 2.0;
|
||||
s.run("caret.set", &json!({"page": 0, "x": c.x + c.w / 2.0, "y": y})).unwrap();
|
||||
s.run("caret.set", &json!({"page": 0, "x": e.x + e.w / 2.0, "y": e.y + e.h / 2.0, "extend": true})).unwrap();
|
||||
s.run("table.resizeDrag", &json!({"page": 0, "x": c.x, "y": y, "toX": c.x + 20.0, "toY": y})).unwrap();
|
||||
assert_eq!(first_table(&s).grid.len(), 3, "no new grid line");
|
||||
assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -509,8 +509,18 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
|||
}
|
||||
// At the start of a paragraph.
|
||||
if para.props.numbering.is_some_and(|n| n.num != 0) {
|
||||
// First Backspace removes the number but keeps the item's indent (Word).
|
||||
// First Backspace removes the number but keeps the item's indent (Word): the text stays
|
||||
// where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it).
|
||||
let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
if p.props.style.as_deref() == Some("ListParagraph") {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_left = text_at;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_first = Some(0.0);
|
||||
}
|
||||
}
|
||||
p.props.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[
|
|||
("3/4", "¾"),
|
||||
];
|
||||
|
||||
/// A built-in entry that is an English word (letters and apostrophes only), not a symbol.
|
||||
fn is_english_entry(from: &str) -> bool {
|
||||
from.chars().any(char::is_alphabetic) && from.chars().all(|c| c.is_alphabetic() || c == '\'')
|
||||
}
|
||||
|
||||
/// Compare and Combine take the same settings.
|
||||
const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
|
||||
|
||||
|
|
@ -162,14 +167,23 @@ impl AutoCorrectPrefs {
|
|||
self
|
||||
}
|
||||
|
||||
/// The replacement for `word`, if any: the user's entry, else a built-in one not deleted.
|
||||
/// Built-in lowercase entries also match a capitalised word ("Teh" → "The").
|
||||
/// The replacement for `word` in English text, if any: the user's entry, else a built-in
|
||||
/// one not deleted. Built-in lowercase entries also match a capitalised word ("Teh" → "The").
|
||||
pub fn replacement(&self, word: &str) -> Option<String> {
|
||||
self.replacement_in(word, true)
|
||||
}
|
||||
|
||||
/// The replacement for `word` in text that is (`english`) or isn't English: the built-in
|
||||
/// English words ("teh", "i", "dont") apply only to English text, as Word keeps a list per
|
||||
/// language (#835: Polish "i" means "and"); symbols, fractions and the user's entries apply
|
||||
/// whatever the language.
|
||||
pub fn replacement_in(&self, word: &str, english: bool) -> Option<String> {
|
||||
if let Some((_, b)) = self.entries.iter().find(|(a, _)| a == word) {
|
||||
return Some(b.clone());
|
||||
}
|
||||
AUTOCORRECT
|
||||
.iter()
|
||||
.filter(|(a, _)| english || !is_english_entry(a))
|
||||
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
|
||||
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
|
||||
.find(|(a, _)| {
|
||||
|
|
@ -364,6 +378,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#,
|
||||
)
|
||||
.pure(),
|
||||
// Word's Search box in the title bar ("Tell me what you want to do"), Alt+Q (#819).
|
||||
CommandSpec::new("tools.search", "Search", "Title Bar", search_commands)
|
||||
.key(super::non_mac("Alt+Q"))
|
||||
.params(r#"{"query"?: string} → {matches: [{id, label, location, shortcut}]} (every word of the query in the label, location or id; at most 50); no query opens the Search box"#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
|
||||
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
|
||||
|
|
@ -779,7 +798,15 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
|||
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
|
||||
}
|
||||
// A whole-word replacement, else "wait..." → "wait…".
|
||||
let found = if ac.replace_text { ac.replacement(&word) } else { None };
|
||||
// The word's language (run, paragraph style, document default) picks the list (#835).
|
||||
// Text with no language at all is in the editing language (a Polish interface writes Polish),
|
||||
// else English. Proofing decides what is English the same way.
|
||||
let english = s.doc.para_at(&f).is_none_or(|p| {
|
||||
let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start));
|
||||
let lang = rc.lang.as_deref().or(s.editing_lang.as_deref());
|
||||
wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::En
|
||||
});
|
||||
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
|
||||
let (from, rep) = match found {
|
||||
Some(r) => (start, Some(r)),
|
||||
None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
|
||||
|
|
@ -1391,6 +1418,31 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
|
|||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
/// `tools.search`: the commands whose label, location or id hold every word of `query`, or,
|
||||
/// with no query, the Search box (`ui_requests` → the UI's command search).
|
||||
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(query) = p::str(v, "query") else {
|
||||
s.ui_requests.push(json!({"open": "commands"}));
|
||||
return Ok(json!({"matches": []}));
|
||||
};
|
||||
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
|
||||
let matches: Vec<Value> = if words.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
s.registry
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
let hay = format!("{} {} {}", c.label, c.location, c.id).to_lowercase();
|
||||
words.iter().all(|w| hay.contains(w.as_str()))
|
||||
})
|
||||
.take(50)
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut}))
|
||||
.collect()
|
||||
};
|
||||
Ok(json!({"matches": matches}))
|
||||
}
|
||||
|
||||
/// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access
|
||||
/// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does
|
||||
/// what they ask (no dialog).
|
||||
|
|
|
|||
|
|
@ -110,7 +110,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 +163,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,7 +26,9 @@ use serde_json::Value;
|
|||
pub use io::Password;
|
||||
pub use keymap::KeyMap;
|
||||
pub use ribbon::RibbonLayout;
|
||||
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState, blank_document, set_system_locale};
|
||||
pub use session::{
|
||||
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
|
||||
};
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_geom::Region;
|
||||
pub use wordcraft_layout as layout;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::Value;
|
||||
use wordcraft_doc::edit::Fragment;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef, Table};
|
||||
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
|
||||
|
||||
use crate::{CmdError, Registry};
|
||||
|
|
@ -51,6 +51,52 @@ pub struct ColumnBlock {
|
|||
pub focus_x: f32,
|
||||
/// One `(start, end)` per line, in document order, each inside one paragraph.
|
||||
pub segments: Vec<(Pos, Pos)>,
|
||||
/// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in
|
||||
/// a table's selection areas): one segment per paragraph of the cells.
|
||||
pub cells: Option<CellRange>,
|
||||
}
|
||||
|
||||
/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into
|
||||
/// grid columns `cols.0..=cols.1`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CellRange {
|
||||
pub story: StoryRef,
|
||||
pub table: Path,
|
||||
pub rows: (usize, usize),
|
||||
pub cols: (usize, usize),
|
||||
/// The cells are one run of the text (cells of one row, or whole rows), so the stream
|
||||
/// selection between the corners holds just them and commands act on that as usual.
|
||||
/// Otherwise (part of a column) text edits and formatting go cell by cell.
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
impl CellRange {
|
||||
/// The selected cells of table `t` (row, cell index), in document order.
|
||||
pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> {
|
||||
let ((r0, r1), (g0, g1)) = (self.rows, self.cols);
|
||||
let mut out = Vec::new();
|
||||
for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) {
|
||||
let mut g = 0usize;
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
let end = g.saturating_add(cell.span());
|
||||
if g <= g1 && end > g0 {
|
||||
out.push((r, c));
|
||||
}
|
||||
g = end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`),
|
||||
/// and where it really runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Widened {
|
||||
/// The selection it became; this is current while `Session::sel` still equals it.
|
||||
pub sel: Selection,
|
||||
/// Where the selection was really extended from and to: extending it again goes on from here.
|
||||
pub ends: Selection,
|
||||
}
|
||||
|
||||
/// View state that commands can change (ribbon View tab, status bar).
|
||||
|
|
@ -230,6 +276,9 @@ struct Undo {
|
|||
sel: Selection,
|
||||
/// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too.
|
||||
pending: Option<CharProps>,
|
||||
/// The column block or whole cells the selection was (undoing brings the selection back as
|
||||
/// it was).
|
||||
column: Option<ColumnBlock>,
|
||||
}
|
||||
|
||||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||||
|
|
@ -387,6 +436,9 @@ pub struct Session {
|
|||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
pub column_mode: bool,
|
||||
/// A selection widened to whole table cells or rows, and where it really runs (see
|
||||
/// [`Session::sel_ends`]).
|
||||
pub(crate) widened: Option<Widened>,
|
||||
/// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back
|
||||
/// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used.
|
||||
edit_marks: Vec<Pos>,
|
||||
|
|
@ -490,6 +542,8 @@ fn doc_bib_style(doc: &Document) -> String {
|
|||
|
||||
/// Maximum undo depth.
|
||||
const MAX_UNDO: usize = 500;
|
||||
/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further.
|
||||
pub const UNDO_LIST_MAX: usize = 100;
|
||||
|
||||
impl Session {
|
||||
/// File name of the persisted custom dictionary inside the settings folder.
|
||||
|
|
@ -561,6 +615,7 @@ impl Session {
|
|||
password: None,
|
||||
column: None,
|
||||
column_mode: false,
|
||||
widened: None,
|
||||
edit_marks: Vec::new(),
|
||||
go_back_step: 0,
|
||||
}
|
||||
|
|
@ -601,7 +656,20 @@ impl Session {
|
|||
/// The line pieces of the current column selection, if there is one (the selection hasn't
|
||||
/// moved since the block was made).
|
||||
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
|
||||
// Cells that are one run of the text are an ordinary stream selection.
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice())
|
||||
}
|
||||
|
||||
/// The whole table cells selected, if the selection is that (see [`CellRange`]).
|
||||
pub fn cell_range(&self) -> Option<&CellRange> {
|
||||
self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref())
|
||||
}
|
||||
|
||||
/// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where
|
||||
/// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds
|
||||
/// those).
|
||||
pub fn sel_ends(&self) -> &Selection {
|
||||
self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends)
|
||||
}
|
||||
|
||||
/// Document revision (bumped by every change).
|
||||
|
|
@ -741,7 +809,13 @@ impl Session {
|
|||
return;
|
||||
}
|
||||
self.typing_open = label == "Typing";
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone(), pending: self.pending.clone() }));
|
||||
self.history.push(Arc::new(Undo {
|
||||
label: label.to_string(),
|
||||
doc: self.doc.clone(),
|
||||
sel: self.sel.clone(),
|
||||
pending: self.pending.clone(),
|
||||
column: self.column.clone(),
|
||||
}));
|
||||
if self.history.len() > MAX_UNDO {
|
||||
self.history.remove(0);
|
||||
self.undo_evicted += 1;
|
||||
|
|
@ -751,7 +825,7 @@ impl Session {
|
|||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||||
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone() }));
|
||||
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone(), column: None }));
|
||||
if self.history.len() > MAX_UNDO {
|
||||
self.history.remove(0);
|
||||
self.undo_evicted += 1;
|
||||
|
|
@ -799,6 +873,21 @@ impl Session {
|
|||
pub fn undo_labels(&self) -> Vec<String> {
|
||||
self.history.iter().rev().map(|u| u.label.clone()).collect()
|
||||
}
|
||||
/// The Undo button's list (#701): the undo steps' labels (English, as commands name them),
|
||||
/// newest first, at most [`UNDO_LIST_MAX`].
|
||||
pub fn undo_history(&self) -> Vec<&str> {
|
||||
self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect()
|
||||
}
|
||||
/// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701).
|
||||
/// More than the undo depth undoes everything; a step that would take the document's
|
||||
/// protection away stops it (only Stop Protection lifts it). Returns the steps undone.
|
||||
pub fn undo_steps(&mut self, count: usize) -> usize {
|
||||
let mut done = 0;
|
||||
while done < count && !self.step_lifts_protection(false) && self.undo() {
|
||||
done += 1;
|
||||
}
|
||||
done
|
||||
}
|
||||
pub fn undo(&mut self) -> bool {
|
||||
self.typing_open = false;
|
||||
let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false };
|
||||
|
|
@ -807,6 +896,7 @@ impl Session {
|
|||
doc: std::mem::replace(&mut self.doc, u.doc),
|
||||
sel: std::mem::replace(&mut self.sel, u.sel),
|
||||
pending: std::mem::replace(&mut self.pending, u.pending),
|
||||
column: std::mem::replace(&mut self.column, u.column),
|
||||
};
|
||||
self.redo.push(Arc::new(cur));
|
||||
self.touch();
|
||||
|
|
@ -825,6 +915,7 @@ impl Session {
|
|||
doc: std::mem::replace(&mut self.doc, u.doc),
|
||||
sel: std::mem::replace(&mut self.sel, u.sel),
|
||||
pending: std::mem::replace(&mut self.pending, u.pending),
|
||||
column: std::mem::replace(&mut self.column, u.column),
|
||||
};
|
||||
self.history.push(Arc::new(cur));
|
||||
self.touch();
|
||||
|
|
@ -874,6 +965,7 @@ impl Session {
|
|||
self.also_selected.clear();
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
self.edit_marks.clear();
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
|
|
@ -955,9 +1047,11 @@ impl Session {
|
|||
// The save password, like the file path: not an edit to the document.
|
||||
password: _,
|
||||
also_selected: _,
|
||||
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
column_mode: _,
|
||||
widened: _,
|
||||
// Go Back's places, like the caret history: not part of the document.
|
||||
edit_marks: _,
|
||||
go_back_step: _,
|
||||
|
|
@ -1089,7 +1183,8 @@ impl Session {
|
|||
self.typing_open = false;
|
||||
}
|
||||
// Only the paste right after a tracked cut makes it a move.
|
||||
if (spec.mutates || matches!(id, "edit.undo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") {
|
||||
if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial")
|
||||
{
|
||||
self.pending_move = None;
|
||||
}
|
||||
let sel_before = self.sel.clone();
|
||||
|
|
@ -1188,6 +1283,11 @@ impl Session {
|
|||
|
||||
/// Selected plain text.
|
||||
pub fn selected_text(&self) -> String {
|
||||
// A column block or whole cells that aren't one run of the text: each piece's own text,
|
||||
// a line each, so the status bar and Word Count count those cells only.
|
||||
if let Some(segs) = self.column_segments() {
|
||||
return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::<Vec<_>>().join("\n");
|
||||
}
|
||||
let (a, b) = self.sel.ordered();
|
||||
self.doc.copy_range(&a, &b).plain_text()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,68 @@ fn typing_enter_undo() {
|
|||
assert_eq!(text(&s), "Hello world");
|
||||
}
|
||||
|
||||
/// The Undo button's list (#701): several steps undone at once, newest first.
|
||||
#[test]
|
||||
fn undo_to_takes_several_steps_back_at_once() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "World"}));
|
||||
let h = run(&mut s, "edit.undoHistory", json!({}));
|
||||
assert_eq!(h["undo"], json!(["Typing", "New Paragraph", "Bold", "Typing"]));
|
||||
assert_eq!(h["depth"], 4);
|
||||
assert_eq!(s.undo_history(), ["Typing", "New Paragraph", "Bold", "Typing"]);
|
||||
// Three steps in one command: World, the paragraph and the bold go; Hello stays.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 3}));
|
||||
assert_eq!(r["undone"], 3);
|
||||
assert_eq!(r["depth"], 1);
|
||||
assert_eq!(text(&s), "Hello");
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(1).bold), Some(None));
|
||||
assert_eq!(s.undo_history(), ["Typing"]);
|
||||
// Each undone step is one redo step.
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Hello\nWorld");
|
||||
// More than the depth undoes everything.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 1e300}));
|
||||
assert_eq!(r["undone"], 4);
|
||||
assert_eq!(text(&s), "");
|
||||
assert!(!s.can_undo());
|
||||
assert!(s.run("edit.undoTo", &json!({"count": 1})).is_err(), "nothing to undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_to_rejects_bad_counts_and_leaves_the_document_alone() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
let before = (s.doc.clone(), s.undo_depth());
|
||||
for junk in
|
||||
[json!({}), json!(null), json!({"count": 0}), json!({"count": -3}), json!({"count": "2"}), json!({"count": [2]}), json!({"count": -1e308})]
|
||||
{
|
||||
assert!(s.run("edit.undoTo", &junk).is_err(), "{junk}");
|
||||
assert_eq!((s.doc.clone(), s.undo_depth()), before, "{junk}");
|
||||
}
|
||||
// A fraction counts whole steps.
|
||||
let r = run(&mut s, "edit.undoTo", json!({"count": 1.9}));
|
||||
assert_eq!(r["undone"], 1);
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_history_is_capped() {
|
||||
let mut s = s();
|
||||
for i in 0..(crate::UNDO_LIST_MAX + 20) {
|
||||
run(&mut s, "para.indents", json!({"left": i as f64}));
|
||||
}
|
||||
assert_eq!(s.undo_history().len(), crate::UNDO_LIST_MAX);
|
||||
assert_eq!(run(&mut s, "edit.undoHistory", json!({}))["undo"].as_array().map(Vec::len), Some(crate::UNDO_LIST_MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_commands_are_one_undo_step() {
|
||||
let mut s = s();
|
||||
|
|
@ -751,6 +813,22 @@ fn clipboard_round_trip() {
|
|||
assert_eq!(text(&s), "plain\ntext");
|
||||
}
|
||||
|
||||
#[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)
|
||||
|
|
@ -1371,6 +1449,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.
|
||||
|
|
@ -1879,6 +2006,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();
|
||||
|
|
@ -2393,6 +2539,35 @@ 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": "1B1611"}), 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// #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.
|
||||
|
|
@ -3654,6 +3829,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.
|
||||
|
|
@ -3753,7 +3953,7 @@ fn drag_after_multi_click_extends_by_units() {
|
|||
let before = text(&s);
|
||||
for v in [
|
||||
json!({}),
|
||||
json!({"unit": "line", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
|
||||
json!({"unit": "page", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
|
||||
json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}),
|
||||
json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}),
|
||||
] {
|
||||
|
|
@ -3762,6 +3962,39 @@ fn drag_after_multi_click_extends_by_units() {
|
|||
assert_eq!(text(&s), before);
|
||||
}
|
||||
|
||||
/// #856: a click in the selection bar selects the whole line (its trailing space; the
|
||||
/// paragraph mark on the last line); dragging extends by lines; triple-click takes the document.
|
||||
#[test]
|
||||
fn selection_bar_selects_and_drags_by_lines() {
|
||||
let mut s = s();
|
||||
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
|
||||
run(&mut s, "text.insert", json!({"text": format!("{}\rShort one.\rLast.", long.trim_end())}));
|
||||
let l = s.layout();
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
let line2 = l.selection_bar_at(c.page, 20.0, c.top + c.height * 1.5).unwrap();
|
||||
assert!(line2.off > 0);
|
||||
run(&mut s, "select.line", json!({"pos": line2.clone()}));
|
||||
let (a, b) = s.sel.ordered();
|
||||
assert_eq!(a, line2);
|
||||
assert_eq!(b.path, wordcraft_doc::Path::top(0));
|
||||
assert!(b.off > a.off && b.off < long.trim_end().len());
|
||||
assert_eq!(s.doc.para_at(&b).unwrap().text.as_bytes()[b.off - 1], b' ', "the trailing space is in");
|
||||
// The last line of a paragraph takes its mark.
|
||||
run(&mut s, "select.line", json!({"pos": Pos::body(1, 3)}));
|
||||
assert_eq!(s.sel.ordered(), (Pos::body(1, 0), Pos::body(2, 0)));
|
||||
// Drag from the "Short one." line down to "Last." and back up into the first paragraph.
|
||||
let (a, b) = s.sel.ordered();
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": Pos::body(2, 0)}));
|
||||
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(1, 0), Pos::body(2, 5)));
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": line2.clone()}));
|
||||
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(2, 0), line2));
|
||||
run(&mut s, "select.dragUnit", json!({"unit": "document", "start": a.clone(), "end": b.clone(), "pos": Pos::body(0, 0)}));
|
||||
assert_eq!(s.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 5)));
|
||||
// Junk positions never panic.
|
||||
let _ = s.run("select.line", &json!({"pos": {"block": 99, "off": usize::MAX}}));
|
||||
let _ = s.run("select.line", &json!({"pos": 5}));
|
||||
}
|
||||
|
||||
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
|
||||
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
|
||||
/// again, and each step undoes.
|
||||
|
|
@ -4466,6 +4699,133 @@ fn format_font_sets_the_asian_and_latin_fonts_separately() {
|
|||
assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err());
|
||||
}
|
||||
|
||||
/// A one-column table (no header row) holding `cells`, in a document whose default language
|
||||
/// is `lang`; the caret ends in the last cell.
|
||||
fn column_table(lang: &str, cells: &[&str]) -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": lang, "default": true}));
|
||||
run(&mut s, "insert.table", json!({"rows": cells.len(), "cols": 1}));
|
||||
for (k, c) in cells.iter().enumerate() {
|
||||
if k > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
if !c.is_empty() {
|
||||
run(&mut s, "text.insert", json!({"text": c}));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn column_texts(s: &Session) -> Vec<String> {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
|
||||
#[test]
|
||||
fn table_sort_reads_decimal_comma_in_polish() {
|
||||
let mut s = column_table("pl-PL", &["12", "1,5", "2"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["1,5", "2", "12"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false, "descending": true}));
|
||||
assert_eq!(column_texts(&s), ["12", "2", "1,5"]);
|
||||
// English keeps the comma as a thousands separator.
|
||||
let mut s = column_table("en-US", &["1,250", "12.5", "2"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["2", "12.5", "1,250"]);
|
||||
}
|
||||
|
||||
/// #836: table formulas read each cell in its language and write the result with its decimal
|
||||
/// separator.
|
||||
#[test]
|
||||
fn table_formula_reads_and_writes_decimal_comma_in_polish() {
|
||||
let mut s = column_table("pl-PL", &["3,50", "2,25", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
|
||||
let mut s = column_table("pl-PL", &["1 250,00", "100", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350");
|
||||
let mut s = column_table("pl-PL", &["1\u{a0}250,50 zł", "100", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1350,50");
|
||||
let mut s = column_table("en-US", &["$1,250.50", "3.25", ""]);
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "1253.75");
|
||||
}
|
||||
|
||||
/// #837: text sorts follow the language's alphabet: Polish `ł` after `l`, `ć` after `c`.
|
||||
#[test]
|
||||
fn table_and_paragraph_sort_use_polish_alphabet() {
|
||||
let mut s = column_table("pl-PL", &["Zakopane", "Łódź", "Kraków"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Kraków", "Łódź", "Zakopane"]);
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("zebra\nćma\ndom"));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "para.sort", json!({}));
|
||||
assert_eq!(text(&s), "ćma\ndom\nzebra");
|
||||
// Swedish puts å ä ö after z; German sorts ä with a.
|
||||
let mut s = column_table("sv-SE", &["ödla", "zebra", "åka", "apa"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["apa", "zebra", "åka", "ödla"]);
|
||||
let mut s = column_table("de-DE", &["Zug", "Äpfel", "Bach"]);
|
||||
run(&mut s, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Äpfel", "Bach", "Zug"]);
|
||||
}
|
||||
|
||||
/// #837: index entries and letter headings follow the language: `Łódź` files under its own
|
||||
/// `Ł` heading between `K` and `Z` in Polish.
|
||||
#[test]
|
||||
fn index_sorts_polish_entries_and_headings() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Zakopane.\nŁódź.\nKraków."));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
for w in ["Zakopane", "Łódź", "Kraków"] {
|
||||
run(&mut s, "select.text", json!({"text": w}));
|
||||
run(&mut s, "references.markEntry", json!({}));
|
||||
}
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "references.index", json!({}));
|
||||
let lines: Vec<String> =
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("ListEntry")).map(|p| p.plain_text()).collect();
|
||||
let order: Vec<&str> = lines.iter().map(|l| l.split([',', '\t']).next().unwrap_or("")).collect();
|
||||
let pos = |w: &str| order.iter().position(|x| *x == w).unwrap_or(usize::MAX);
|
||||
assert!(pos("Kraków") < pos("Łódź") && pos("Łódź") < pos("Zakopane"), "{lines:?}");
|
||||
}
|
||||
|
||||
/// Review of #844: a numbered column (`1.`, `2.`, `10.`) sorts as numbers; text with no language
|
||||
/// at all follows the editing language (a Polish interface reads `3,50` as 3.5).
|
||||
#[test]
|
||||
fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
let mut t = column_table("pl-PL", &["10.", "2.", "1."]);
|
||||
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&t), ["1.", "2.", "10."]);
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "3,50"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "2,25"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
assert_eq!(run(&mut s, "table.formula", json!({"formula": "=SUM(ABOVE)"}))["result"], "5,75");
|
||||
}
|
||||
|
||||
/// Review of #844: the bibliography lists Polish authors in the Polish alphabet.
|
||||
#[test]
|
||||
fn bibliography_sorts_polish_authors() {
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
for author in ["Mazur, Jan", "Łukasiewicz, Jan", "Lis, Anna"] {
|
||||
run(&mut s, "references.citation", json!({"source": {"author": author, "title": format!("Książka {author}"), "year": "2020"}}));
|
||||
run(&mut s, "text.insert", json!({"text": " "}));
|
||||
}
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "references.bibliography", json!({}));
|
||||
let all = text(&s);
|
||||
// The list, not the citations before it.
|
||||
let t = all.split_once("References").map_or(all.as_str(), |(_, list)| list);
|
||||
let at = |name: &str| t.find(&format!("{name}, ")).unwrap_or_else(|| panic!("{name} in {all}"));
|
||||
assert!(at("Lis") < at("Łukasiewicz") && at("Łukasiewicz") < at("Mazur"), "{t}");
|
||||
}
|
||||
|
||||
/// A new blank document takes its paper from the region, and lengths are shown in the region's
|
||||
/// unit with its decimal separator; an explicit Measurement units choice still wins (#838).
|
||||
#[test]
|
||||
|
|
@ -4527,3 +4887,122 @@ fn new_documents_follow_the_region() {
|
|||
assert_eq!(t.region(), wordcraft_geom::Region::default());
|
||||
assert_eq!(t.prefs.region.as_deref(), Some("en-US"), "junk is saved as en-US");
|
||||
}
|
||||
|
||||
/// Automatic (HTML-style) paragraph spacing: `para.spacing` turns it on and off, and a number
|
||||
/// replaces it, as typing one in Word's Paragraph dialog does; Ctrl+0 and Remove Space After work
|
||||
/// on it too.
|
||||
#[test]
|
||||
fn spacing_commands_handle_automatic_spacing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Pasted from the web"}));
|
||||
let rp = |s: &Session| s.doc.styles.resolve_para(&s.doc.para_at(&s.sel.focus).unwrap().props);
|
||||
run(&mut s, "para.spacing", json!({"beforeAuto": true, "afterAuto": true}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).space_after, rp(&s).before_auto), (14.0, 14.0, true));
|
||||
run(&mut s, "para.spacing", json!({"before": 6}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).before_auto, rp(&s).after_auto), (6.0, false, true), "a number replaces Auto");
|
||||
run(&mut s, "para.removeSpaceAfter", json!({}));
|
||||
assert_eq!((rp(&s).space_after, rp(&s).after_auto), (0.0, false));
|
||||
run(&mut s, "para.spacing", json!({"beforeAuto": true}));
|
||||
run(&mut s, "para.addSpaceBefore", json!({}));
|
||||
assert_eq!((rp(&s).space_before, rp(&s).before_auto), (0.0, false), "Ctrl+0 removes the automatic space");
|
||||
assert!(s.run("para.spacing", &json!({"beforeAuto": "yes"})).is_ok(), "junk is ignored, never a panic");
|
||||
}
|
||||
|
||||
/// #712, as in Word: Ctrl+F (`edit.find` with no text) opens the Navigation pane and asks the UI
|
||||
/// to put the keyboard in its search box, not the dialog; Ctrl+H (`edit.replace` with nothing to
|
||||
/// replace) opens Find and Replace on its Replace tab instead of failing; Go To moves by pages from
|
||||
/// the caret's page (`pageBy`).
|
||||
#[test]
|
||||
fn find_replace_and_go_to_open_like_word() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "cat dog cat"}));
|
||||
run(&mut s, "edit.find", json!({}));
|
||||
assert!(s.view.nav_pane);
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"focus": "navSearch"})));
|
||||
assert!(!s.ui_requests.iter().any(|r| r.get("open").is_some()), "no dialog: {:?}", s.ui_requests);
|
||||
s.dirty = false;
|
||||
run(&mut s, "edit.findReplace", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "replace"})));
|
||||
assert!(!s.dirty, "opening the dialog changes nothing");
|
||||
assert!(s.run("edit.findReplace", &json!({"tab": "nope"})).is_err());
|
||||
let reg = s.registry.clone();
|
||||
assert_eq!(s.keymap.resolve(®, "Mod+H").map(|c| c.id), Some("edit.findReplace"));
|
||||
run(&mut s, "edit.goto", json!({}));
|
||||
assert_eq!(s.ui_requests.last(), Some(&json!({"open": "goto"})));
|
||||
// Replace with a "with" and the last search still replaces, as before.
|
||||
run(&mut s, "edit.find", json!({"text": "dog"}));
|
||||
run(&mut s, "edit.replace", json!({"with": "cow"}));
|
||||
assert_eq!(text(&s), "cat cow cat");
|
||||
|
||||
// Five pages; Go To +2 / -1 from where the caret is, clamped to the document.
|
||||
let mut s = super::tests::s();
|
||||
for i in 0..5 {
|
||||
if i > 0 {
|
||||
run(&mut s, "insert.pageBreak", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": format!("Page {}", i + 1)}));
|
||||
}
|
||||
let page = |s: &mut Session| s.layout().caret_on(&s.sel.focus, s.page_hint).unwrap().page + 1;
|
||||
run(&mut s, "edit.goto", json!({"page": 1}));
|
||||
assert_eq!(page(&mut s), 1);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": 2}));
|
||||
assert_eq!(page(&mut s), 3);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": -1}));
|
||||
assert_eq!(page(&mut s), 2);
|
||||
run(&mut s, "edit.goto", json!({"pageBy": 99}));
|
||||
assert_eq!(page(&mut s), 5, "past the end: the last page");
|
||||
run(&mut s, "edit.goto", json!({"pageBy": -99}));
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,9 +104,12 @@ fn shift_tab_and_tab_change_list_levels() {
|
|||
fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() {
|
||||
let mut s = typed("* One\nTwo");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
let text_x = |s: &mut Session| s.layout().caret(&Pos::body(1, 0)).map(|c| c.x);
|
||||
let before = text_x(&mut s);
|
||||
typ(&mut s, "\u{8}");
|
||||
// Number gone, still indented with the list.
|
||||
// Number gone, still indented with the list: the text stays where it was.
|
||||
assert_eq!(paras(&s)[1], ("Two".to_string(), None, true));
|
||||
assert_eq!(text_x(&mut s), before);
|
||||
typ(&mut s, "\u{8}|");
|
||||
// Second Backspace: Normal paragraph, not joined to the one above.
|
||||
assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]);
|
||||
|
|
@ -185,6 +188,48 @@ fn autocorrect_as_you_type() {
|
|||
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
|
||||
}
|
||||
|
||||
/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays
|
||||
/// lowercase; symbols still change; English and untagged text still get "I".
|
||||
#[test]
|
||||
fn autocorrect_replacement_list_follows_the_texts_language() {
|
||||
// Polish as the document's default language.
|
||||
let mut s = s();
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL", "default": true}));
|
||||
typ(&mut s, "Ala i kot. kawa i herbata teh (c) ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot. Kawa i herbata teh © ");
|
||||
// Polish on the run only: the word's own language counts.
|
||||
let mut s = self::s();
|
||||
typ(&mut s, "Ala");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "pl-PL"}));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
typ(&mut s, " i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ");
|
||||
// English (any region) and untagged text (the default proofing language is English).
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": "en-GB", "default": true}));
|
||||
typ(&mut s, "so i think teh end ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
|
||||
let s = typed("so i think teh end ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think the end ");
|
||||
// A Polish interface: a new document is Polish by its default language, and text with no
|
||||
// language at all (a .docx without `w:lang`) follows the editing language too.
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
typ(&mut s, "Ala i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
typ(&mut s, "Ala i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language");
|
||||
// `eng` is English too, as for proofing.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "review.language", json!({"lang": "eng", "default": true}));
|
||||
typ(&mut s, "so i think ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "So I think ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_options_change_what_typing_does() {
|
||||
let mut s = s();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -855,11 +866,12 @@ fn style_italic(style: &str) -> bool {
|
|||
|
||||
impl FontDb {
|
||||
/// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first,
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts; empty
|
||||
/// so they are the fallback for Japanese text after the requested and bundled fonts), then the
|
||||
/// craft-fonts Simplified Chinese faces, for the ideographs the Japanese fonts lack (both empty
|
||||
/// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
|
||||
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(crate::chinese_fonts()).map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for f in enumerate_faces(data) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
|
|
@ -1196,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 {
|
||||
|
|
@ -1210,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))
|
||||
}
|
||||
|
||||
|
|
@ -1568,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);
|
||||
|
|
@ -269,8 +275,10 @@ fn fallback_reads_each_installed_font_file_once_however_many_characters_miss() {
|
|||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let found = db.fallback_for('\u{30000}', latin.id()).expect("the installed font maps it");
|
||||
assert_eq!((found.family.as_str(), found.style.as_str()), (FAMILY, "Regular"));
|
||||
// 2,000 distinct characters nothing maps: the index answers, no file is read again.
|
||||
for cp in 0x30001..0x30001 + 2000 {
|
||||
// 2,000 distinct characters nothing maps (Supplementary Private Use Area-A: no font here,
|
||||
// bundled or craft-fonts, maps it; CJK Extension G after U+30000 is partly in Noto Sans CJK
|
||||
// SC): the index answers, no file is read again.
|
||||
for cp in 0xF_0001..0xF_0001 + 2000 {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
assert!(db.fallback_for(c, latin.id()).is_none());
|
||||
}
|
||||
|
|
@ -403,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,7 @@ impl<'r> Builder<'r> {
|
|||
covered: false,
|
||||
header: name == "th",
|
||||
shading,
|
||||
borders: None,
|
||||
});
|
||||
fmt.background = None;
|
||||
self.containers.push(Vec::new());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -796,6 +871,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);
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -280,6 +280,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]
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -720,7 +720,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(&[]);
|
||||
|
|
|
|||
|
|
@ -213,6 +213,15 @@ fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
|
|||
Some(Pos { story, path: path.clone(), off: para.off_at_x(li, u) })
|
||||
}
|
||||
|
||||
/// The body line on `page` whose band is nearest height `y` (the leftmost on a tie, so in
|
||||
/// columns the first column wins); only top-level paragraphs' lines when `top_only`.
|
||||
fn nearest_line_at<'a>(page: &'a Page, y: f32, top_only: bool) -> Option<LineHit<'a>> {
|
||||
page_lines(page, StoryRef::Body)
|
||||
.into_iter()
|
||||
.filter(|l| !top_only || l.path.0.len() <= 1)
|
||||
.min_by(|a, b| band_dist(y, a.top, a.bottom - a.top).total_cmp(&band_dist(y, b.top, b.bottom - b.top)).then(a.left.total_cmp(&b.left)))
|
||||
}
|
||||
|
||||
/// How far `v` is outside the band `top..top + h`.
|
||||
fn band_dist(v: f32, top: f32, h: f32) -> f32 {
|
||||
if v < top {
|
||||
|
|
@ -476,6 +485,51 @@ impl DocLayout {
|
|||
None
|
||||
}
|
||||
|
||||
/// Word's selection bar (#856): when (x, y) is in the page's left margin, left of the body
|
||||
/// text column, the start of the body line at that height. Not in the header/footer area,
|
||||
/// not beside a table's lines (those are left to the table), nothing on an empty page.
|
||||
pub fn selection_bar_at(&self, page: usize, x: f32, y: f32) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if !(x.is_finite() && y.is_finite()) || x < 0.0 || x >= p.body.x || y < p.body.y || y > p.body.bottom() {
|
||||
return None;
|
||||
}
|
||||
let l = nearest_line_at(p, y, false)?;
|
||||
if l.path.0.len() > 1 || l.left < p.body.x - 0.5 {
|
||||
// A table's line, or text hanging into the margin: not the selection bar.
|
||||
return None;
|
||||
}
|
||||
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
|
||||
}
|
||||
|
||||
/// The start of the top-level body line nearest height `y` on `page` (dragging in the
|
||||
/// selection bar extends line by line, wherever the pointer is across).
|
||||
pub fn body_line_at(&self, page: usize, y: f32) -> Option<Pos> {
|
||||
let p = self.pages.get(page)?;
|
||||
if !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let l = nearest_line_at(p, y, true)?;
|
||||
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
|
||||
}
|
||||
|
||||
/// The whole visual line holding `pos`, trailing spaces and break included: (start, end,
|
||||
/// whether it is the paragraph's last line). What a click in the selection bar selects.
|
||||
pub fn line_span(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos, bool)> {
|
||||
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, .. } = it else { continue };
|
||||
let li = para.line_of(pos.off);
|
||||
if li < *l0 || li >= *l1 {
|
||||
continue;
|
||||
}
|
||||
let l = para.lines.get(li)?;
|
||||
let next = para.lines.get(li.saturating_add(1));
|
||||
let end = next.map_or(l.stop, |n| n.start).max(l.start);
|
||||
let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };
|
||||
return Some((mk(l.start), mk(end), next.is_none()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Column (block) selection: the piece of every line from the line holding `a` to the line
|
||||
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
|
||||
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use wordcraft_geom::{Point, Rect, Spin};
|
|||
pub use fields::FieldCtx;
|
||||
pub use hit::VisualStep;
|
||||
pub use para::{LineEnd, ParaLayout};
|
||||
pub use table::{autofit_widths, measure_table_columns};
|
||||
pub use table::{autofit_widths, cell_margins, measure_table_columns};
|
||||
|
||||
/// How the document is viewed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -600,6 +600,9 @@ struct Ctx<'a> {
|
|||
uses_totals: bool,
|
||||
/// Whether to note `uses_totals` now (off while laying out headers and footers).
|
||||
track_totals: bool,
|
||||
/// Body blocks that are empty paragraphs carrying only a section break before a continuous
|
||||
/// section, in order: they take no room (see [`place_para`]).
|
||||
quiet_marks: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
|
|
@ -872,21 +875,61 @@ fn layout_box(
|
|||
let mut float_tables: Vec<Rect> = Vec::new();
|
||||
let mut y = 0.0f32;
|
||||
let mut prev_after = 0.0f32;
|
||||
let mut prev_after_auto = false;
|
||||
let mut prev_listed = false;
|
||||
let mut prev_style: Option<(String, bool)> = None;
|
||||
let mut last_top = 0.0f32;
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
last_top = y;
|
||||
let mut path = prefix.to_vec();
|
||||
path.push(i as u32);
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let label = ctx.next_label(p);
|
||||
let listed = label.is_some();
|
||||
let mut pl = ctx.para_labelled(p, width, table, &[], label.clone());
|
||||
// A text frame (a footer's page number in a frame, say) is placed apart, like a
|
||||
// floating object, and the text after it wraps around it; it takes no room in
|
||||
// the flow.
|
||||
if let Some(fr) = pl.rp.frame.clone() {
|
||||
let fw = fr.w.unwrap_or_else(|| natural_width(&pl)).clamp(1.0, 1584.0);
|
||||
let pf = if (fw - width).abs() > 0.01 { ctx.para_labelled(p, fw, table, &[], label) } else { pl };
|
||||
let h = match fr.h {
|
||||
Some(h) if fr.h_exact => h,
|
||||
Some(h) => h.max(pf.height),
|
||||
None => pf.height,
|
||||
};
|
||||
let float = frame_float(&fr);
|
||||
let r = float_rect(frame, (0.0, width), y, fw, h, &float);
|
||||
push_para(&mut items, story, &path, &pf, 0, pf.lines.len(), r.x, r.y, fw);
|
||||
excl.extend(wrap_area(r, &float, None));
|
||||
float_tables.push(r);
|
||||
continue;
|
||||
}
|
||||
let ctxl = pl.rp.contextual_spacing;
|
||||
let same = prev_style.as_ref().is_some_and(|(s, c)| *s == pl.rp.style && (*c || ctxl));
|
||||
let before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
||||
let mut before = if same && ctxl { 0.0 } else { pl.rp.space_before };
|
||||
if same && ctxl {
|
||||
y -= prev_after;
|
||||
prev_after = 0.0;
|
||||
}
|
||||
// Automatic spacing: none between two list items (as in an HTML list).
|
||||
if i > 0 && prev_listed && listed {
|
||||
if prev_after_auto {
|
||||
y -= prev_after;
|
||||
prev_after = 0.0;
|
||||
}
|
||||
if pl.rp.before_auto {
|
||||
before = 0.0;
|
||||
}
|
||||
}
|
||||
// Automatic spacing: none at the top of a cell or box, and it collapses with the
|
||||
// space after the paragraph above (the larger one counts).
|
||||
if pl.rp.before_auto && i == 0 {
|
||||
before = 0.0;
|
||||
} 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.
|
||||
|
|
@ -917,8 +960,12 @@ fn layout_box(
|
|||
items.splice(at..at, back);
|
||||
items.extend(front);
|
||||
}
|
||||
y += pl.height + pl.rp.space_after;
|
||||
prev_after = if same && ctxl { 0.0 } else { pl.rp.space_after };
|
||||
// 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 = after;
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
prev_listed = listed;
|
||||
prev_style = Some((pl.rp.style.clone(), ctxl));
|
||||
}
|
||||
Block::Table(t) => {
|
||||
|
|
@ -958,15 +1005,64 @@ fn layout_box(
|
|||
}
|
||||
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
|
||||
ctx.hide_deleted = outer_hide;
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom }
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom, last_top: last_top.max(0.0) }
|
||||
}
|
||||
|
||||
/// A laid-out box: items relative to its top-left, the height of its text flow, and where its
|
||||
/// floating tables end (a table cell grows to hold them).
|
||||
/// The width a text frame without one takes: its widest line, with the paragraph's indents.
|
||||
fn natural_width(pl: &ParaLayout) -> f32 {
|
||||
let widest = (0..pl.lines.len())
|
||||
.filter_map(|li| {
|
||||
let l = pl.lines.get(li)?;
|
||||
Some((pl.x_of(li, l.stop)? - pl.x_of(li, l.start)?).abs())
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
widest + pl.rp.indent_left.max(0.0) + pl.rp.indent_right.max(0.0)
|
||||
}
|
||||
|
||||
/// A text frame's placement as a floating object's (`w:framePr` → anchors, alignment, wrap).
|
||||
fn frame_float(fr: &wordcraft_doc::props::TextFrame) -> Float {
|
||||
let anchor = |a: &str, text: Anchor| match a {
|
||||
"page" => Anchor::Page,
|
||||
"margin" => Anchor::Margin,
|
||||
_ => text,
|
||||
};
|
||||
let align = |a: Option<&str>| match a {
|
||||
Some("left" | "top") => Some(FloatAlign::Start),
|
||||
Some("center") => Some(FloatAlign::Center),
|
||||
Some("right" | "bottom") => Some(FloatAlign::End),
|
||||
Some("inside") => Some(FloatAlign::Inside),
|
||||
Some("outside") => Some(FloatAlign::Outside),
|
||||
_ => None,
|
||||
};
|
||||
Float {
|
||||
wrap: match fr.wrap.as_str() {
|
||||
"notBeside" => Wrap::TopAndBottom,
|
||||
"none" => Wrap::InFrontOfText,
|
||||
"tight" => Wrap::Tight,
|
||||
"through" => Wrap::Through,
|
||||
_ => Wrap::Square,
|
||||
},
|
||||
h_rel: anchor(&fr.h_anchor, Anchor::Column),
|
||||
v_rel: anchor(&fr.v_anchor, Anchor::Paragraph),
|
||||
x: fr.x,
|
||||
y: fr.y,
|
||||
h_align: align(fr.x_align.as_deref()),
|
||||
v_align: align(fr.y_align.as_deref()),
|
||||
dist: fr.h_space,
|
||||
dist_top: fr.v_space,
|
||||
dist_bottom: fr.v_space,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A laid-out box: items relative to its top-left, the height of its text flow, where its
|
||||
/// floating tables end (a table cell grows to hold them) and where its last block starts (the
|
||||
/// height without it).
|
||||
struct BoxLayout {
|
||||
items: Vec<Placed>,
|
||||
height: f32,
|
||||
floats_bottom: f32,
|
||||
last_top: f32,
|
||||
}
|
||||
|
||||
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
||||
|
|
@ -1026,8 +1122,11 @@ fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f3
|
|||
r
|
||||
}
|
||||
|
||||
/// A text box's internal margins (Word's defaults: 0.1" left/right, 0.05" top/bottom).
|
||||
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
|
||||
/// own come from its [`wordcraft_doc::TextBody`].
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_X: f32 = 7.2;
|
||||
#[cfg(test)]
|
||||
const BOX_INSET_Y: f32 = 3.6;
|
||||
|
||||
/// Keep what fits in a box `height` tall (items relative to its top): lines that don't fit are
|
||||
|
|
@ -1141,8 +1240,12 @@ struct PageBuilder<'a> {
|
|||
carry: Vec<NoteBox>,
|
||||
/// Page bottom before footnotes took space.
|
||||
orig_bottom: f32,
|
||||
/// The previous paragraph: (style, contextual spacing, space after) for contextual spacing.
|
||||
prev: Option<(String, bool, f32)>,
|
||||
/// The paragraph above: its style, contextual spacing, space after, whether that is automatic
|
||||
/// and whether it is a list item.
|
||||
prev: Option<(String, bool, f32, bool, bool)>,
|
||||
/// The previous paragraph ended with a page break (its mark stayed with the break): the next
|
||||
/// paragraph, first on the new page, gets no space before (Word).
|
||||
after_break_mark: bool,
|
||||
/// Wrap areas of floating objects on this page (see [`wrap_area`]).
|
||||
excl: Vec<WrapArea>,
|
||||
/// Wrap areas of the floating tables on this page, which others may not overlap.
|
||||
|
|
@ -1683,11 +1786,18 @@ fn layout_pass(
|
|||
grid: para::Grid::default(),
|
||||
uses_totals: false,
|
||||
track_totals: true,
|
||||
quiet_marks: Vec::new(),
|
||||
};
|
||||
let sections = doc.sections();
|
||||
let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline);
|
||||
let read = opts.view == ViewMode::Read;
|
||||
let reflow = web || read;
|
||||
ctx.quiet_marks = sections
|
||||
.windows(2)
|
||||
.filter(|w| w[1].1.start == SectionStart::Continuous)
|
||||
.map(|w| w[0].0)
|
||||
.filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()))
|
||||
.collect();
|
||||
let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default()));
|
||||
let default_sect = SectionProps::default();
|
||||
let mut web_sect;
|
||||
|
|
@ -1729,6 +1839,7 @@ fn layout_pass(
|
|||
carry: Vec::new(),
|
||||
orig_bottom: 0.0,
|
||||
prev: None,
|
||||
after_break_mark: false,
|
||||
excl: Vec::new(),
|
||||
float_tables: Vec::new(),
|
||||
line_no: 0,
|
||||
|
|
@ -1803,18 +1914,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();
|
||||
|
|
@ -2166,6 +2287,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 };
|
||||
|
|
@ -2367,6 +2515,7 @@ fn anchor_floats(
|
|||
pb: &mut PageBuilder,
|
||||
p: &Paragraph,
|
||||
y0: f32,
|
||||
before: f32,
|
||||
excl_mark: usize,
|
||||
label: &Option<(String, Level)>,
|
||||
) -> (HashMap<usize, Rect>, Arc<ParaLayout>) {
|
||||
|
|
@ -2384,7 +2533,8 @@ fn anchor_floats(
|
|||
if left_out(oi) {
|
||||
continue;
|
||||
}
|
||||
let r = float_rect(Some(frame), (col_x, width), y0, w, h, float);
|
||||
// Positions relative to the paragraph start above its space before (Word for Windows).
|
||||
let r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
|
||||
rects.insert(oi, r);
|
||||
let outline = ctx.wrap_outline(o);
|
||||
if let Some(area) = wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())) {
|
||||
|
|
@ -2512,7 +2662,25 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32
|
|||
notes
|
||||
}
|
||||
|
||||
/// Place a body paragraph. An empty paragraph that only carries a section break before a
|
||||
/// continuous section takes no room (Word for Windows: the text after it follows right on, and
|
||||
/// columns balanced above it don't count it).
|
||||
fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) {
|
||||
if !pb.web && ctx.quiet_marks.binary_search(&block).is_ok() {
|
||||
// Its (empty) line stays where the text ended, even at the bottom of a full column.
|
||||
let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col));
|
||||
pb.bottom = f32::MAX / 4.0;
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
pb.bottom = bottom;
|
||||
if (pb.pages.len(), pb.col) == at {
|
||||
(pb.y, pb.prev, pb.content_bottom) = (y, prev, content_bottom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
place_para_in_flow(ctx, pb, p, block, body_top);
|
||||
}
|
||||
|
||||
fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) {
|
||||
let width = pb.col_w();
|
||||
// Floating objects anchored here join the page's wrap areas before the text is laid out; if
|
||||
// the paragraph then starts somewhere else (another column or page, or contextual spacing
|
||||
|
|
@ -2521,42 +2689,84 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
let excl_mark = pb.excl.len();
|
||||
let guess = pb.y + space_before;
|
||||
let label = ctx.next_label(p);
|
||||
let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, excl_mark, &label);
|
||||
// The anchor paragraph's top the floats were placed from.
|
||||
let mut anchor_y = guess;
|
||||
let (mut float_rects, mut pl) = anchor_floats(ctx, pb, p, guess, space_before, excl_mark, &label);
|
||||
// The anchor paragraph's top (above its space before) the floats were placed from.
|
||||
let mut anchor_y = pb.y;
|
||||
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
|
||||
let placed_at = spot(pb);
|
||||
if pl.rp.page_break_before && !pb.at_top() && !pb.web {
|
||||
let broke_before = pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
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;
|
||||
if let Some((style, ctxl, after)) = pb.prev.take()
|
||||
&& style == pl.rp.style
|
||||
let prev_para = pb.prev.clone();
|
||||
// At the top of a page, measured in Word for Windows: no space before after a paragraph that
|
||||
// ended with a page break, nor (from Word 2013's layout, compatibility mode 15) for a page
|
||||
// break before; a section break or, in older modes, a page break before keeps it.
|
||||
let after_break_mark = std::mem::take(&mut pb.after_break_mark);
|
||||
if pb.at_top() && (after_break_mark || (broke_before && ctx.doc.settings.compat_mode >= 15)) {
|
||||
before = 0.0;
|
||||
} else if pb.at_top()
|
||||
&& let Some((_, _, after, _, _)) = &prev_para
|
||||
{
|
||||
// After a page break before or a section break the space between the paragraphs is
|
||||
// still counted as one: the previous paragraph's space after (left on its page) comes
|
||||
// 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);
|
||||
}
|
||||
if let Some((style, ctxl, mut after, after_auto, listed)) = pb.prev.take()
|
||||
&& !pb.at_top()
|
||||
{
|
||||
if ctxl {
|
||||
pb.y -= after;
|
||||
// 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 at the top of the document.
|
||||
if pl.rp.before_auto && block == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
pb.y += before;
|
||||
// Keep lines together: if it doesn't fit but would on an empty column, move it.
|
||||
if pl.rp.keep_lines || pl.rp.keep_next {
|
||||
let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width) } else { 0.0 };
|
||||
let mut applied_before = before;
|
||||
// Keep lines together: if it doesn't fit but would on an empty column, move it. Keep with
|
||||
// next alone moves a paragraph only when it would fit here by itself (Word for Windows): one
|
||||
// that doesn't breaks as usual, its last lines going over with the next paragraph.
|
||||
if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + pl.height <= pb.bottom + 0.01) {
|
||||
let need = pl.height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0) } else { 0.0 };
|
||||
// (Against the whole column: balanced columns grow to hold it.)
|
||||
if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top {
|
||||
pb.advance(block, body_top);
|
||||
pb.y += pl.rp.space_before.min(0.0);
|
||||
applied_before = pl.rp.space_before.min(0.0);
|
||||
}
|
||||
}
|
||||
// A different top only matters when there is something to wrap around.
|
||||
if spot(pb) != placed_at || ((pb.y - guess).abs() > 0.01 && !pb.excl.is_empty()) {
|
||||
let y0 = pb.y;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label);
|
||||
anchor_y = y0;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, applied_before, excl_mark, &label);
|
||||
anchor_y = y0 - applied_before;
|
||||
}
|
||||
let mut n = pl.lines.len();
|
||||
let mut l0 = 0;
|
||||
|
|
@ -2567,6 +2777,9 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
let lead = if l0 == 0 { first.top.max(0.0) } else { 0.0 };
|
||||
let mut l1 = l0;
|
||||
let mut forced_break = None;
|
||||
// A page break followed only by the paragraph mark: the mark's (empty) line stays on this
|
||||
// page with the break, so nothing of the paragraph goes to the next page (Word).
|
||||
let mut mark_with_break = false;
|
||||
let mut new_notes: Vec<NoteBox> = Vec::new();
|
||||
let notes = para_notes(ctx.doc, p, &pl);
|
||||
while l1 < n {
|
||||
|
|
@ -2620,6 +2833,10 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
l1 += 1;
|
||||
if matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) && !pb.web {
|
||||
forced_break = Some(l.end);
|
||||
if l.end == LineEnd::PageBreak && l1 + 1 == n && pl.lines.get(l1).is_some_and(|m| m.start >= m.stop) {
|
||||
l1 += 1;
|
||||
mark_with_break = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if split {
|
||||
|
|
@ -2645,7 +2862,7 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
pb.advance(block, body_top);
|
||||
if l0 == 0 {
|
||||
let y0 = pb.y;
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, excl_mark, &label);
|
||||
(float_rects, pl) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label);
|
||||
anchor_y = y0;
|
||||
n = pl.lines.len();
|
||||
}
|
||||
|
|
@ -2730,18 +2947,46 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
|
|||
pb.content_bottom = pb.content_bottom.max(pb.y);
|
||||
}
|
||||
l0 = l1;
|
||||
// A paragraph that starts with a page break starts on the new page, its space before
|
||||
// there (Word).
|
||||
let starts_with_break = l0 == 1
|
||||
&& pl.lines.first().is_some_and(|l| {
|
||||
p.text.get(l.start..l.stop).is_some_and(|t| !t.is_empty() && t.chars().all(|c| c == wordcraft_doc::para::PAGE_BREAK))
|
||||
});
|
||||
match forced_break {
|
||||
Some(LineEnd::PageBreak) if mark_with_break => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.after_break_mark = true;
|
||||
}
|
||||
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
|
||||
pb.new_page(block, body_top);
|
||||
pb.y += pl.rp.space_before.max(0.0);
|
||||
}
|
||||
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
|
||||
Some(LineEnd::ColumnBreak) => {
|
||||
pb.col_break_page = Some(pb.pages.len().saturating_sub(1));
|
||||
pb.advance(block, body_top)
|
||||
pb.advance(block, body_top);
|
||||
// Unequal columns: the text after the break is broken for its own column's width
|
||||
// (Word), not the width of the column it started in.
|
||||
if (pb.col_w() - width).abs() > 0.5
|
||||
&& let Some(resume) = pl.lines.get(l0).map(|l| l.start)
|
||||
{
|
||||
let y0 = pb.y;
|
||||
let (rects, relaid) = anchor_floats(ctx, pb, p, y0, 0.0, excl_mark, &label);
|
||||
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
|
||||
(float_rects, pl) = (rects, relaid);
|
||||
anchor_y = y0;
|
||||
n = pl.lines.len();
|
||||
l0 = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if l0 < n => pb.advance(block, body_top),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pb.y += pl.rp.space_after;
|
||||
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after));
|
||||
pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after, pl.rp.after_auto, label.is_some()));
|
||||
}
|
||||
|
||||
/// The outline of the body for an Outline view pass.
|
||||
|
|
@ -2811,7 +3056,10 @@ fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: boo
|
|||
}
|
||||
|
||||
/// Height of the first line of the block after `block` (for keep-with-next).
|
||||
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
/// 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
|
||||
/// (a chain moves together in Word).
|
||||
fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32 {
|
||||
match ctx.doc.body.get(block + 1).map(|b| &**b) {
|
||||
Some(Block::Para(p)) => {
|
||||
// Don't advance list counters for a lookahead: lay out without the label.
|
||||
|
|
@ -2835,7 +3083,11 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
|||
};
|
||||
let _ = rp;
|
||||
let pl = para::layout_para(p, &env);
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
if pl.rp.keep_next && depth < 8 {
|
||||
pl.rp.space_before + pl.height + pl.rp.space_after + next_first_line(ctx, block + 1, width, depth + 1)
|
||||
} else {
|
||||
pl.rp.space_before + pl.lines.first().map(|l| l.height).unwrap_or(0.0)
|
||||
}
|
||||
}
|
||||
Some(Block::Table(_)) => 24.0,
|
||||
None => 0.0,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -730,6 +783,7 @@ impl<'a> Builder<'a> {
|
|||
if let Some(p) = cur {
|
||||
segs.push((seg_start, text.len(), p));
|
||||
}
|
||||
let no_kern = [wordcraft_fonts::feature_value(b"kern", 0)];
|
||||
for (a, b, key) in segs {
|
||||
let Some(sub) = text.get(a..b) else { continue };
|
||||
let rc = match (&rc_cs, key.complex) {
|
||||
|
|
@ -758,11 +812,22 @@ 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 {
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, &[], map, key.rtl)
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
} else {
|
||||
wordcraft_fonts::shape(&st.face, shape_text, &[], map)
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
|
|
@ -1079,14 +1144,27 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
|||
if rp.indent_chars.iter().any(|c| *c != 0.0) {
|
||||
rp.set_char_unit(doc.styles.resolve_char_in(para_style, table_chr, &p.mark).size);
|
||||
}
|
||||
// List level indents apply unless the paragraph sets its own.
|
||||
// List level indents apply unless the paragraph sets its own, or, for a list from the style,
|
||||
// the style giving the list (or one based on it) does (`indents_over_list`). A paragraph
|
||||
// that switches its style's list off (`num: 0`) drops the styles' indents, as Word does.
|
||||
let own_left = p.props.indent_left.is_some() || p.props.indent_left_chars.is_some_and(|c| c != 0.0);
|
||||
let own_first = p.props.indent_first.is_some() || p.props.indent_first_chars.is_some_and(|c| c != 0.0);
|
||||
if let Some((_, lvl)) = &env.label {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
let (style_left, style_first) =
|
||||
if p.props.numbering.is_some() { (false, false) } else { doc.styles.indents_over_list(para_style.unwrap_or("Normal")) };
|
||||
if !own_left && !style_left {
|
||||
rp.indent_left = lvl.indent;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
if !own_first && !style_first {
|
||||
rp.indent_first = -lvl.hanging;
|
||||
}
|
||||
} else if p.props.numbering.is_some_and(|n| n.num == 0) {
|
||||
if !own_left {
|
||||
rp.indent_left = 0.0;
|
||||
}
|
||||
if !own_first {
|
||||
rp.indent_first = 0.0;
|
||||
}
|
||||
}
|
||||
let resolve = |c: &CharProps| -> Arc<ResolvedChar> { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) };
|
||||
let levels = bidi_levels(&p.text, rp.bidi);
|
||||
|
|
@ -1363,24 +1441,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();
|
||||
|
|
@ -1395,18 +1465,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 {
|
||||
|
|
@ -1781,7 +1851,10 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
|||
let mut any = false;
|
||||
let dropped = pl.drop_cap.map_or(0, |d| d.0);
|
||||
for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() {
|
||||
if c.kind == ClKind::Marker || c0 + k < dropped {
|
||||
// Spaces and tabs never make a line taller, whatever their size: Word sizes a line by
|
||||
// its other characters (a line of only them takes the paragraph mark's), measured in
|
||||
// Word for Windows with 9 pt text and 11–20 pt tabs, spaces and ideographic spaces.
|
||||
if matches!(c.kind, ClKind::Marker | ClKind::Space | ClKind::Tab) || c0 + k < dropped {
|
||||
continue;
|
||||
}
|
||||
if let Some(st) = pl.styles.get(c.style as usize) {
|
||||
|
|
@ -1810,10 +1883,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
|||
asc = st.ascent;
|
||||
desc = st.descent;
|
||||
}
|
||||
// A list label can make the line taller above the baseline, never below it: Word keeps
|
||||
// the text's descent (measured in Word for Windows: a Symbol bullet's taller ascent
|
||||
// counts, a Courier New label's deeper descent doesn't).
|
||||
if first && let Some(lab) = &pl.label {
|
||||
for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) {
|
||||
asc = asc.max(st.ascent);
|
||||
desc = desc.max(st.descent);
|
||||
}
|
||||
}
|
||||
// Line spacing scales the text's height, never a picture's: a line is at least as tall
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ fn default_margins(t: &Table, style: Option<&TableStyleProps>) -> [f32; 4] {
|
|||
t.props.cell_margins.or(style.and_then(|s| s.parts.cell_margins)).unwrap_or(DEFAULT_MARGINS)
|
||||
}
|
||||
|
||||
/// The margins (top, left, bottom, right; points) inside cell `cell` of row `row`: its own, else
|
||||
/// the table's defaults, as the layout uses them. The app's ruler measures a cell's indents from
|
||||
/// them.
|
||||
pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: usize) -> [f32; 4] {
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
t.rows.get(row).and_then(|r| r.cells.get(cell)).and_then(|c| c.props.margins).unwrap_or_else(|| default_margins(t, style.as_ref()))
|
||||
}
|
||||
|
||||
/// The most paragraphs [`measure_table_columns`] lays out: a huge table is measured by its first
|
||||
/// rows only, so AutoFit stays quick.
|
||||
const MEASURE_BUDGET: usize = 4000;
|
||||
|
|
@ -411,6 +419,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let mut rh = 0.0f32;
|
||||
// The tallest cell margins plus border bands in the row.
|
||||
let mut row_insets = 0.0f32;
|
||||
// Every cell an empty one with a hidden end-of-cell mark: the row is only its borders.
|
||||
let mut collapsed = true;
|
||||
let is_header = header_rows && ri == 0;
|
||||
let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1;
|
||||
let band = banded(t, style.as_ref(), ri);
|
||||
|
|
@ -497,6 +507,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
let (items, h) = if cell.props.vmerge == VMerge::Continue {
|
||||
(Vec::new(), 0.0)
|
||||
} else if turn.is_turned() {
|
||||
collapsed = false;
|
||||
// Turned text: its lines run along the cell's height. An exact row height sets
|
||||
// their length; otherwise they get the length the text needs unwrapped and the row
|
||||
// grows to fit (the second pass lays them out again if the row ends up taller).
|
||||
|
|
@ -509,12 +520,20 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
natural_length(&probe).clamp(4.0, TURNED_MAX)
|
||||
}
|
||||
};
|
||||
let BoxLayout { items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
|
||||
let BoxLayout { items, height, floats_bottom, .. } = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
|
||||
let across = height.max(floats_bottom);
|
||||
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
|
||||
(items, len)
|
||||
} else {
|
||||
let BoxLayout { mut items, height, floats_bottom } = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
|
||||
let BoxLayout { mut items, mut height, floats_bottom, last_top } =
|
||||
layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
|
||||
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
|
||||
// Windows: a row of empty such cells is only its borders).
|
||||
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
|
||||
height = last_top;
|
||||
} else {
|
||||
collapsed = false;
|
||||
}
|
||||
for it in &mut items {
|
||||
it.translate(x0 + margins[1], margins[0] + band_t);
|
||||
}
|
||||
|
|
@ -549,7 +568,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
|||
(Some(h), _) if h > 0.0 => rh.max(h + row_insets),
|
||||
_ => rh,
|
||||
};
|
||||
heights.push(rh.max(4.0));
|
||||
heights.push(if collapsed && !cells.is_empty() { rh.max(0.0) } else { rh.max(4.0) });
|
||||
rows.push(cells);
|
||||
}
|
||||
// Vertically merged cells: grow the last row of the merge if the content is taller.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,39 @@ fn hit_and_caret_agree() {
|
|||
assert_eq!(up.path, Path::top(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_bar_finds_the_line_beside_the_pointer() {
|
||||
// #856: the left margin beside a line is Word's selection bar.
|
||||
let text = format!("{}\nShort.", "The quick brown fox jumps over the lazy dog. ".repeat(8));
|
||||
let mut d = Document::from_text(&text);
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(Table::new(1, 1, 468.0))).unwrap();
|
||||
let l = lay(&d);
|
||||
let p = &l.pages[0];
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
let first = l.selection_bar_at(0, 30.0, c.top + c.height / 2.0).unwrap();
|
||||
assert_eq!(first, Pos::body(0, 0));
|
||||
// The second line of the wrapped paragraph: its start, and its span is the whole line.
|
||||
let line2 = l.selection_bar_at(0, 30.0, c.top + c.height * 1.5).unwrap();
|
||||
assert_eq!(line2.path, Path::top(0));
|
||||
assert!(line2.off > 0);
|
||||
let (a, b, last) = l.line_span(&line2, 0).unwrap();
|
||||
assert_eq!(a, line2);
|
||||
assert!(b.off > a.off && !last);
|
||||
let (_, b, last) = l.line_span(&Pos::body(1, 0), 0).unwrap();
|
||||
assert_eq!(b.off, "Short.".len());
|
||||
assert!(last);
|
||||
// In the text column, the header area, off the page or beside a table: not the bar.
|
||||
assert_eq!(l.selection_bar_at(0, p.body.x + 5.0, c.top + 2.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, 30.0, p.body.y - 10.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, -5.0, c.top + 2.0), None);
|
||||
assert_eq!(l.selection_bar_at(0, f32::NAN, c.top), None);
|
||||
assert_eq!(l.selection_bar_at(9, 30.0, c.top), None);
|
||||
let cell = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![2, 0, 0, 0]), off: 0 }).unwrap();
|
||||
assert_eq!(l.selection_bar_at(0, 30.0, cell.top + cell.height / 2.0), None);
|
||||
// Dragging takes the nearest top-level line at any height.
|
||||
assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletions_leave_the_final_text_layout() {
|
||||
// "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED".
|
||||
|
|
@ -526,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");
|
||||
|
|
@ -865,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
|
||||
|
|
@ -2156,6 +2302,38 @@ fn at_least_rows_add_cell_margins_and_border_bands() {
|
|||
assert!((tops[0] - (72.0 + 0.5 + 5.0)).abs() < 0.01, "first cell text at {}", tops[0]);
|
||||
}
|
||||
|
||||
/// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's;
|
||||
/// each cell's text starts its left margin inside the cell.
|
||||
#[test]
|
||||
fn cell_margins_are_where_cell_text_starts() {
|
||||
let mut d = Document::from_text("");
|
||||
let mut t = Table::new(1, 2, 300.0);
|
||||
t.props.cell_margins = Some([0.0, 9.0, 0.0, 3.0]);
|
||||
t.rows[0].cells[1].props.margins = Some([0.0, 20.0, 0.0, 4.0]);
|
||||
for (c, text) in t.rows[0].cells.iter_mut().zip(["one", "two"]) {
|
||||
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
}
|
||||
assert_eq!(cell_margins(&d, &t, 0, 0), [0.0, 9.0, 0.0, 3.0]);
|
||||
assert_eq!(cell_margins(&d, &t, 0, 1), [0.0, 20.0, 0.0, 4.0]);
|
||||
assert_eq!(cell_margins(&d, &t, 5, 9), [0.0, 9.0, 0.0, 3.0], "no such cell: the table's");
|
||||
assert_eq!(cell_margins(&d, &Table::new(1, 1, 100.0), 0, 0), [0.0, 5.4, 0.0, 5.4], "Word's");
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t.clone()))];
|
||||
let l = lay(&d);
|
||||
let items = &l.pages[0].items;
|
||||
for c in 0..2 {
|
||||
let boxed = items.iter().find_map(|it| match it {
|
||||
Placed::Cell { rect, cell, .. } if *cell == c => Some(*rect),
|
||||
_ => None,
|
||||
});
|
||||
let text_x = items.iter().find_map(|it| match it {
|
||||
Placed::Lines { path, x, .. } if path.0.get(2) == Some(&(c as u32)) => Some(*x),
|
||||
_ => None,
|
||||
});
|
||||
let (boxed, text_x) = (boxed.unwrap(), text_x.unwrap());
|
||||
assert!((text_x - boxed.x - cell_margins(&d, &t, 0, c)[1]).abs() < 0.01, "cell {c}: text at {text_x}, cell at {}", boxed.x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Line count and, per line, how far the text (trailing spaces excluded) reaches.
|
||||
fn justified_lines(text: &str, compat_mode: u32, align: Align) -> (usize, Vec<f32>) {
|
||||
let mut d = Document::from_text(text);
|
||||
|
|
@ -2342,8 +2520,10 @@ fn float_moved_to_a_new_page_sits_at_its_paragraph_top() {
|
|||
let l = lay(&d);
|
||||
let r = shapes(&l.pages[1].items)[0];
|
||||
let top = first_line_top(&l.pages[1].items);
|
||||
// The space before counts once: the picture and the text both start below it.
|
||||
assert!((r.y - top).abs() < 0.5, "picture at {} but its paragraph's text starts at {top}", r.y);
|
||||
// The picture sits at the paragraph's start, above its space before, as in Word (`plan/word`
|
||||
// R22); the text starts below the space before.
|
||||
let body_top = d.last_section.margin_top;
|
||||
assert!((r.y - body_top).abs() < 0.5 && top >= body_top, "picture at {} but its paragraph starts at {body_top}", r.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4143,3 +4323,602 @@ fn bulgarian_text_is_hyphenated_by_its_own_rules() {
|
|||
assert!(wordcraft_proof::hyphen::hyphen_points_in(&word, &lim, wordcraft_proof::ProofLang::Bg).contains(&at), "{head}-{tail}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spaces and tabs never make a line taller in Word, whatever their size (measured in Word for
|
||||
/// Windows: 9 pt text with 11–20 pt tabs, spaces and ideographic spaces kept the 9 pt line; a
|
||||
/// line of only big spaces took the paragraph mark's height; a big letter or no-break space did
|
||||
/// grow it). Tables of contents with big tab runs were a third taller than Word's.
|
||||
#[test]
|
||||
fn spaces_and_tabs_do_not_make_a_line_taller() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let small = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
let pitch = |middle: &str, middle_props: &CharProps| {
|
||||
let mut d = Document::new();
|
||||
d.body = (0..3)
|
||||
.map(|i| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(&format!("L{i}A"), small.clone());
|
||||
let n = p.len();
|
||||
p.insert_text(n, middle, middle_props).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_text(n, "B", &small).unwrap();
|
||||
p.props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
p.mark = small.clone();
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
let l = lay(&d);
|
||||
l.caret(&Pos::body(1, 0)).unwrap().top - l.caret(&Pos::body(0, 0)).unwrap().top
|
||||
};
|
||||
let plain = pitch(" ", &small);
|
||||
for (what, middle) in [("a tab", "\t"), ("a space", " "), ("two spaces", " "), ("an ideographic space", "\u{3000}")] {
|
||||
assert!((pitch(middle, &big) - plain).abs() < 0.01, "{what} at 20 pt: {} vs {plain}", pitch(middle, &big));
|
||||
}
|
||||
assert!(pitch("x", &big) > plain + 5.0, "a 20 pt letter does make it taller");
|
||||
assert!(pitch("\u{A0}", &big) > plain + 5.0, "so does a no-break space");
|
||||
}
|
||||
|
||||
/// A list label taller than its text makes the first line taller above the baseline only: Word
|
||||
/// keeps the text's descent below it (measured in Word for Windows, `plan/word` R18: a Symbol
|
||||
/// bullet's taller ascent counts, a Courier New label's deeper descent doesn't). NIH's bulleted
|
||||
/// items each came out a fifth of a point taller, enough to push a paragraph's last lines over
|
||||
/// the page bottom.
|
||||
#[test]
|
||||
fn a_list_label_adds_ascent_but_not_descent() {
|
||||
let first_line = |label_size: Option<f32>| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for a in &mut d.numbering.abstracts {
|
||||
for l in &mut a.levels {
|
||||
l.chr.size = label_size;
|
||||
}
|
||||
}
|
||||
let ten = wordcraft_doc::props::CharProps { size: Some(10.0), ..Default::default() };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Item", ten.clone());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
p.mark = ten;
|
||||
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().cloned() } else { None }).unwrap()
|
||||
};
|
||||
let plain = first_line(None);
|
||||
let big = first_line(Some(30.0));
|
||||
let below = |l: &crate::para::Line| l.top + l.height - l.baseline;
|
||||
assert!(big.baseline - big.top > plain.baseline - plain.top + 5.0, "the big label raises the baseline");
|
||||
assert!((below(&big) - below(&plain)).abs() < 0.01, "the text's descent: {} vs {}", below(&big), below(&plain));
|
||||
}
|
||||
|
||||
/// A cell's hidden end-of-cell mark (`w:hideMark`), measured in Word for Windows (`plan/word`
|
||||
/// R20, 9 cases): its empty last paragraph takes no room, spacing and all, so a row of such empty
|
||||
/// cells is only its borders; one ordinary empty cell keeps the row a line tall, and a paragraph
|
||||
/// with text is never hidden. NIH's Table A showed a blank row Word doesn't.
|
||||
#[test]
|
||||
fn a_hidden_cell_mark_takes_no_room() {
|
||||
let row3_top = |hide: [bool; 2], second: Option<&str>| {
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(3, 2, 300.0);
|
||||
for (r, text) in [(0, "R1"), (2, "R3")] {
|
||||
for c in 0..2 {
|
||||
t.rows[r].cells[c].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
}
|
||||
}
|
||||
for (cell, hide) in t.rows[1].cells.iter_mut().zip(hide) {
|
||||
cell.props.hide_mark = hide;
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(text) = second {
|
||||
blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default())));
|
||||
}
|
||||
let mut mark = wordcraft_doc::Paragraph::with_text("", Default::default());
|
||||
mark.props.space_before = Some(10.0);
|
||||
blocks.push(wordcraft_doc::para_block(mark));
|
||||
cell.blocks = blocks;
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
|
||||
// Rows 1 and 2 together.
|
||||
at(2) - at(0)
|
||||
};
|
||||
// Row 1 is one line of text; row 2's height is what's left.
|
||||
let line = {
|
||||
let both = row3_top([false, false], Some("Tx"));
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(2, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R1", Default::default()))];
|
||||
t.rows[1].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("R3", Default::default()))];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |r: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, r, 0, 0]), off: 0 }).unwrap().top;
|
||||
assert!(both > at(1) - at(0));
|
||||
at(1) - at(0)
|
||||
};
|
||||
let row2 = |hide: [bool; 2], second: Option<&str>| row3_top(hide, second) - line;
|
||||
let hidden = row2([true, true], None);
|
||||
assert!(hidden < 1.5, "a row of hidden marks is only its borders: {hidden} vs a line of {line}");
|
||||
let mixed = row2([true, false], None);
|
||||
let plain = row2([false, false], None);
|
||||
assert!(plain > 5.0 && (mixed - plain).abs() < 0.01, "an ordinary empty cell keeps the row: {mixed} vs {plain}");
|
||||
let text = row2([true, true], Some("Tx"));
|
||||
assert!((text - line).abs() < 0.5, "text stays, the empty mark after it goes: {text} vs {line}");
|
||||
}
|
||||
|
||||
/// A text frame in a header, footer or cell (NIH's footers put the page number in one, centred,
|
||||
/// with the footer text wrapping around it) is placed apart: it takes no room in the flow, sits
|
||||
/// where its alignment says, and the text beside it keeps clear of it. WordCraft laid it out as
|
||||
/// an ordinary paragraph, a line of its own above the footer text.
|
||||
#[test]
|
||||
fn a_text_frame_in_a_cell_floats_beside_the_text() {
|
||||
use wordcraft_doc::props::TextFrame;
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
let mut framed = wordcraft_doc::Paragraph::with_text("7", Default::default());
|
||||
framed.props.frame = Some(TextFrame {
|
||||
x_align: Some("right".into()),
|
||||
h_anchor: "text".into(),
|
||||
v_anchor: "text".into(),
|
||||
wrap: "around".into(),
|
||||
..Default::default()
|
||||
});
|
||||
let text = wordcraft_doc::Paragraph::with_text("Cell text", Default::default());
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(framed), wordcraft_doc::para_block(text)];
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
let at = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap();
|
||||
let (frame, text) = (at(0), at(1));
|
||||
assert!((frame.top - text.top).abs() < 0.5, "the frame takes no room: {} vs {}", frame.top, text.top);
|
||||
assert!(frame.x > text.x + 200.0, "right-aligned in the cell: {} vs {}", frame.x, text.x);
|
||||
}
|
||||
|
||||
/// Where a list paragraph's text starts when its style has indents of its own (measured in Word,
|
||||
/// `plan/word` R16): the list level's indents go on top of the most derived style that gives the
|
||||
/// list, so that style's own indent (and those of styles based on it) win, its base styles' don't
|
||||
/// and the paragraph's own list wins over every style; a paragraph that switches the list off
|
||||
/// (`num: 0`) drops the styles' indents. NIH's Heading 3 (its own 0.9" on Heading 2's list) sat at
|
||||
/// the list's 0.6", and its "sample text" paragraphs with the list off at 0.9" instead of the margin.
|
||||
#[test]
|
||||
fn list_indents_and_style_indents_combine_like_word() {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
let text_x = |style: &str, own: ParaProps| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let style_of = |id: &str, based: Option<&str>, para: ParaProps| Style {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: based.map(Into::into),
|
||||
para,
|
||||
..Default::default()
|
||||
};
|
||||
let list = ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() };
|
||||
let ind = ParaProps { indent_left: Some(64.8), ..Default::default() };
|
||||
// List and indent in one style; list in the base, indent in the derived one; the reverse.
|
||||
d.styles.upsert(style_of("Both", None, ParaProps { indent_left: Some(64.8), ..list.clone() }));
|
||||
d.styles.upsert(style_of("ListBase", None, list.clone()));
|
||||
d.styles.upsert(style_of("IndentOnList", Some("ListBase"), ind.clone()));
|
||||
d.styles.upsert(style_of("IndentBase", None, ind));
|
||||
d.styles.upsert(style_of("ListOnIndent", Some("IndentBase"), list.clone()));
|
||||
let own =
|
||||
ParaProps { style: Some(style.into()), numbering: own.numbering.map(|n| NumRef { num: if n.num == 0 { 0 } else { num }, ..n }), ..own };
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text", Default::default());
|
||||
p.props = own;
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
lay(&d).caret(&Pos::body(0, 0)).unwrap().x
|
||||
};
|
||||
let listed = || ParaProps { numbering: Some(NumRef { num: 1, level: 0 }), ..Default::default() };
|
||||
let off = || ParaProps { numbering: Some(NumRef { num: 0, level: 0 }), ..Default::default() };
|
||||
let cases = [
|
||||
("the style's own indent beats its list's", text_x("Both", ParaProps::default()), 72.0 + 64.8),
|
||||
("a style based on the list's style wins too", text_x("IndentOnList", ParaProps::default()), 72.0 + 64.8),
|
||||
("the list's style beats its base style", text_x("ListOnIndent", ParaProps::default()), 108.0),
|
||||
("the paragraph's own list beats every style", text_x("Both", listed()), 108.0),
|
||||
("list off: the style's indent goes too", text_x("Both", off()), 72.0),
|
||||
("list off drops an indent from any style", text_x("IndentBase", off()), 72.0),
|
||||
("list off keeps the paragraph's own indent", text_x("Both", ParaProps { indent_left: Some(10.0), ..off() }), 82.0),
|
||||
];
|
||||
for (what, got, want) in cases {
|
||||
assert!((got - want).abs() < 0.5, "{what}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep with next at a page bottom, measured in Word for Windows (`plan/word` R19, 20 cases): a
|
||||
/// paragraph that would fit on the page by itself moves over with the next one, but one that
|
||||
/// doesn't fit anyway breaks as usual, its last lines going over with the next paragraph (with
|
||||
/// Keep lines together it moves whole); a chain of kept paragraphs moves together. NIH's
|
||||
/// five-line kept paragraph left a third of a page empty.
|
||||
#[test]
|
||||
fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() {
|
||||
// Page of each of the kept paragraph's 5 lines and of the first paragraph after it.
|
||||
let pages = |fillers: usize, kept: ParaProps, next: ParaProps| {
|
||||
let mut d = Document::new();
|
||||
let plain = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)),
|
||||
..Default::default()
|
||||
};
|
||||
let para = |text: &str, p: &ParaProps| {
|
||||
let mut x = wordcraft_doc::Paragraph::with_text(text, Default::default());
|
||||
x.props = ParaProps { keep_next: p.keep_next, keep_lines: p.keep_lines, ..plain.clone() };
|
||||
wordcraft_doc::para_block(x)
|
||||
};
|
||||
d.body = (0..fillers).map(|i| para(&format!("F{i}"), &plain)).collect();
|
||||
d.body.push(para("Ka\nKb\nKc\nKd\nKe", &kept));
|
||||
d.body.push(para("NX", &next));
|
||||
d.body.push(para("NY", &plain));
|
||||
let l = lay(&d);
|
||||
let k = fillers;
|
||||
[0, 3, 6, 9, 12]
|
||||
.map(|off| l.caret(&Pos::body(k, off)).unwrap().page)
|
||||
.into_iter()
|
||||
.chain([l.caret(&Pos::body(k + 1, 0)).unwrap().page])
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let none = ParaProps::default();
|
||||
let kn = ParaProps { keep_next: Some(true), ..Default::default() };
|
||||
let kl = ParaProps { keep_lines: Some(true), ..kn.clone() };
|
||||
// Find where the kept paragraph just fits: its last line on page 1, the next one's on page 2.
|
||||
let fit = (20..80).find(|n| pages(*n, none.clone(), none.clone()) == [0, 0, 0, 0, 0, 1]).expect("a filler count that fills the page");
|
||||
assert_eq!(pages(fit, kn.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "fits by itself: moves with the next");
|
||||
let split = pages(fit + 1, none.clone(), none.clone());
|
||||
assert!(split[0] == 0 && split[4] == 1, "one more line splits it: {split:?}");
|
||||
assert_eq!(pages(fit + 1, kn.clone(), none.clone()), split, "doesn't fit anyway: breaks as usual");
|
||||
assert_eq!(pages(fit + 1, kl.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "with keep lines together it moves whole");
|
||||
assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together");
|
||||
}
|
||||
|
||||
/// An empty paragraph that only carries a section break before a continuous section takes no
|
||||
/// room, measured in Word for Windows (`plan/word` R21, 10 cases): the text after it follows right
|
||||
/// on, and columns balanced above it don't count it (two one-line paragraphs in two columns sit
|
||||
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
|
||||
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
|
||||
/// many continuous breaks.
|
||||
#[test]
|
||||
fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// "Pa", the section's last paragraph (`mark`), "Pb", "Pc" in the next section of `cols`
|
||||
// columns, then "Pd" after a second break.
|
||||
let tops = |mark: &str, cols: u32, start: SectionStart| {
|
||||
let mut d = Document::from_text(&format!("Pa\n{mark}\nPb\nPc\n\nPd"));
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
let mut s = SectionProps { start, ..Default::default() };
|
||||
s.columns.count = cols;
|
||||
d.para_mut(StoryRef::Body, &Path::top(4)).unwrap().section = Some(Box::new(s));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
let l = lay(&d);
|
||||
[0, 2, 3, 5].map(|i| {
|
||||
let c = l.caret(&Pos::body(i, 0)).unwrap();
|
||||
(c.page, (c.top * 10.0).round() / 10.0, c.x)
|
||||
})
|
||||
};
|
||||
let one = tops("", 1, SectionStart::Continuous);
|
||||
let line = one[2].1 - one[1].1;
|
||||
assert!((one[1].1 - one[0].1 - line).abs() < 0.15, "Pb right under Pa: {one:?}");
|
||||
assert!((one[3].1 - one[2].1 - line).abs() < 0.15, "Pd right under Pc: {one:?}");
|
||||
let text = tops("Mark", 1, SectionStart::Continuous);
|
||||
assert!((text[1].1 - text[0].1 - 2.0 * line).abs() < 0.15, "a mark with text keeps its line: {text:?}");
|
||||
let two = tops("", 2, SectionStart::Continuous);
|
||||
assert_eq!(two[1].1, two[2].1, "balanced: Pb and Pc side by side: {two:?}");
|
||||
assert!(two[2].2 > two[1].2 + 100.0, "Pc in the second column: {two:?}");
|
||||
assert!((two[3].1 - two[1].1 - line).abs() < 0.15, "Pd a line below them: {two:?}");
|
||||
let new_page = tops("", 1, SectionStart::NextPage);
|
||||
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
|
||||
}
|
||||
|
||||
/// A floating object positioned relative to its paragraph is measured from the paragraph's start
|
||||
/// above its space before, measured in Word for Windows (`plan/word` R22, 10 cases: 0, 10 and 20
|
||||
/// pt before, offsets 0 and −7.2 pt; the previous paragraph's space after still counts). The SARP
|
||||
/// form's field boxes sat a space-before too low, over their labels.
|
||||
#[test]
|
||||
fn paragraph_relative_floats_start_above_the_space_before() {
|
||||
let shape_top = |before: f32, prev_after: f32, y: f32| {
|
||||
let float = wordcraft_doc::para::Float {
|
||||
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
|
||||
h_rel: wordcraft_doc::para::Anchor::Column,
|
||||
v_rel: wordcraft_doc::para::Anchor::Paragraph,
|
||||
y,
|
||||
..Default::default()
|
||||
};
|
||||
let shape = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::Rectangle,
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
freeform: None,
|
||||
effects: Default::default(),
|
||||
extra: Default::default(),
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(prev_after), ..Default::default() };
|
||||
let mut first = wordcraft_doc::Paragraph::with_text("First", Default::default());
|
||||
first.props = plain;
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Text ", Default::default());
|
||||
p.text.push(wordcraft_doc::para::OBJ);
|
||||
p.objects = vec![shape];
|
||||
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: Default::default() }];
|
||||
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), ..Default::default() };
|
||||
let mut d = Document::from_text("");
|
||||
d.body = vec![wordcraft_doc::para_block(first), wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
let first_bottom = {
|
||||
let c = l.caret(&Pos::body(0, 0)).unwrap();
|
||||
c.top + c.height
|
||||
};
|
||||
let top = l.pages[0].items.iter().find_map(|i| if let Placed::Shape { rect, .. } = i { Some(rect.y) } else { None }).unwrap();
|
||||
(top, first_bottom)
|
||||
};
|
||||
for before in [0.0, 10.0, 20.0] {
|
||||
for y in [0.0, -7.2] {
|
||||
let (top, first_bottom) = shape_top(before, 0.0, y);
|
||||
assert!((top - (first_bottom + y)).abs() < 1.0, "{before} pt before, offset {y}: {top} vs {}", first_bottom + y);
|
||||
}
|
||||
}
|
||||
let (top, first_bottom) = shape_top(0.0, 10.0, 0.0);
|
||||
assert!((top - (first_bottom + 10.0)).abs() < 1.0, "below the previous paragraph's space after: {top} vs {first_bottom}");
|
||||
}
|
||||
|
||||
/// Unequal columns (a narrow label column and a wide text column, as in NIH's SARP form): text
|
||||
/// after a column break is broken for the column it lands in. WordCraft broke it for the narrow
|
||||
/// column it started in, so a paragraph took a dozen lines a word or two wide.
|
||||
#[test]
|
||||
fn text_after_a_column_break_takes_the_new_column_s_width() {
|
||||
let mut d = Document::new();
|
||||
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: vec![(67.0, 52.0), (348.0, 0.0)] };
|
||||
let brk = wordcraft_doc::para::COLUMN_BREAK;
|
||||
let text = format!("Label{brk}Provide a description of the planned recruitment methods including use of contact lists and databases.");
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text, Default::default()))];
|
||||
let l = lay(&d);
|
||||
let lines: Vec<(f32, usize)> =
|
||||
l.pages[0].items.iter().filter_map(|it| if let Placed::Lines { x, l0, l1, .. } = it { Some((*x, l1 - l0)) } else { None }).collect();
|
||||
let (wide_x, wide_lines) = lines.last().copied().unwrap();
|
||||
assert!(wide_x > 72.0 + 67.0, "the text is in the second column: {lines:?}");
|
||||
assert!(wide_lines <= 3, "broken for the 348 pt column, not the 67 pt one: {lines:?}");
|
||||
}
|
||||
|
||||
/// Space before at the top of a page, measured in Word for Windows (32 cases): a paragraph that
|
||||
/// ends with a page break keeps its mark with the break (nothing of it goes to the new page) and the
|
||||
/// next paragraph gets no space before there; a paragraph that starts with a page break starts on
|
||||
/// the new page with its space before; a page break before keeps the space before in compatibility
|
||||
/// modes up to 14 (less the previous paragraph's space after) and drops it in mode 15.
|
||||
#[test]
|
||||
fn space_before_at_the_top_of_a_page_like_word() {
|
||||
let para = |text: &str, before: f32, pbb: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
|
||||
p.props = ParaProps { space_before: Some(before), space_after: Some(0.0), page_break_before: Some(pbb), ..Default::default() };
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let probe_top = |blocks: Vec<std::sync::Arc<wordcraft_doc::Block>>, mode: u32| {
|
||||
let mut d = Document::new();
|
||||
d.settings.compat_mode = mode;
|
||||
let n = blocks.len();
|
||||
d.body = blocks;
|
||||
let l = lay(&d);
|
||||
let c = l.caret(&Pos::body(n - 1, 0)).unwrap();
|
||||
(c.page, c.top)
|
||||
};
|
||||
let plain_top = probe_top(vec![para("first", 0.0, false), para("PROBE", 0.0, true)], 15).1;
|
||||
let brk = wordcraft_doc::para::PAGE_BREAK;
|
||||
// A paragraph ending with a page break, then one with 24 pt before: no space, in both modes.
|
||||
for mode in [12, 15] {
|
||||
let (page, top) = probe_top(vec![para(&format!("first{brk}"), 0.0, false), para("PROBE", 24.0, false)], mode);
|
||||
assert_eq!(page, 1, "mode {mode}");
|
||||
assert!((top - plain_top).abs() < 0.05, "mode {mode}: the mark stays with the break and the space goes: {top} vs {plain_top}");
|
||||
}
|
||||
// A paragraph starting with a page break: its space before on the new page.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para("first", 0.0, false), para(&format!("{brk}PROBE"), 24.0, false)];
|
||||
let l = lay(&d);
|
||||
let c = l.caret(&Pos::body(1, brk.len_utf8())).unwrap();
|
||||
assert_eq!(c.page, 1);
|
||||
assert!((c.top - plain_top - 24.0).abs() < 0.05, "{} vs {}", c.top, plain_top + 24.0);
|
||||
// Page break before: kept up to mode 14, dropped in 15.
|
||||
let (_, kept) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 14);
|
||||
let (_, dropped) = probe_top(vec![para("first", 0.0, false), para("PROBE", 24.0, true)], 15);
|
||||
assert!((kept - plain_top - 24.0).abs() < 0.05 && (dropped - plain_top).abs() < 0.05, "{kept} {dropped} {plain_top}");
|
||||
// After a page break before (mode 14) the previous paragraph's space after comes off: 24 - 10.
|
||||
let mut first = wordcraft_doc::Paragraph::with_text("first", Default::default());
|
||||
first.props = ParaProps { space_before: Some(0.0), space_after: Some(10.0), ..Default::default() };
|
||||
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first.clone()), para("PROBE", 24.0, true)], 14);
|
||||
assert!((top - plain_top - 14.0).abs() < 0.05, "{top} vs {}", plain_top + 14.0);
|
||||
// More after than before: nothing left.
|
||||
first.props.space_after = Some(30.0);
|
||||
let (_, top) = probe_top(vec![wordcraft_doc::para_block(first), para("PROBE", 24.0, true)], 14);
|
||||
assert!((top - plain_top).abs() < 0.05, "{top} vs {plain_top}");
|
||||
// The caret can still reach the end of a paragraph whose mark stayed with its page break.
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para(&format!("first{brk}"), 0.0, false), para("next", 0.0, false)];
|
||||
let l = lay(&d);
|
||||
assert!(l.caret(&Pos::body(0, "first".len() + brk.len_utf8())).is_some(), "caret after the break");
|
||||
}
|
||||
|
||||
/// Word kerns a run only when Kerning for fonts (`w:kern`) is set and the font is at least that
|
||||
/// size; WordCraft kerned everything, so its lines held more text than Word's (measured in Word for
|
||||
/// Windows with Calibri at 14 pt: no `w:kern`, `w:kern="0"` and a threshold above the size are
|
||||
/// all unkerned).
|
||||
#[test]
|
||||
fn kerning_follows_kerning_for_fonts_like_word() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let width = |kern: Option<f32>| {
|
||||
let mut d = Document::new();
|
||||
let chr = CharProps { size: Some(14.0), kern, ..Default::default() };
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("AVAVAV To Wo Ty Yo AWAY", chr))];
|
||||
let l = lay(&d);
|
||||
let end = d.para_at(&Pos::body(0, 0)).map_or(0, |p| p.len());
|
||||
l.caret(&Pos::body(0, end)).unwrap().x - l.caret(&Pos::body(0, 0)).unwrap().x
|
||||
};
|
||||
let plain = width(Some(0.0));
|
||||
assert!(width(Some(1.0)) < plain - 1.0, "kerned from 1 pt: {} vs {plain}", width(Some(1.0)));
|
||||
assert!((width(Some(14.0)) - width(Some(1.0))).abs() < 0.01, "a threshold equal to the size kerns");
|
||||
assert!((width(Some(15.0)) - plain).abs() < 0.01, "a threshold above the size doesn't kern");
|
||||
// A new document kerns from 1 pt, like Word's Normal template.
|
||||
assert!((width(None) - width(Some(1.0))).abs() < 0.01, "new documents kern");
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.kern = None;
|
||||
assert!(d.styles.resolve_char(None, &Default::default()).kern == 0.0, "no Kerning for fonts: not kerned");
|
||||
}
|
||||
|
||||
/// Word's automatic (HTML-style) paragraph spacing, measured in Word for Windows: 14 pt at any
|
||||
/// size; none before the first paragraph of the document or a cell, none after the last one of a
|
||||
/// cell; next to another paragraph's space the larger of the two counts.
|
||||
#[test]
|
||||
fn automatic_paragraph_spacing_like_word() {
|
||||
let auto = |before: bool, after: bool| ParaProps {
|
||||
space_before: Some(5.0),
|
||||
space_after: Some(5.0),
|
||||
space_before_auto: before.then_some(true),
|
||||
space_after_auto: after.then_some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
let tops = |props: [ParaProps; 3]| {
|
||||
let mut d = Document::new();
|
||||
d.body = ["FIRST", "SECOND", "THIRD"]
|
||||
.iter()
|
||||
.zip(props)
|
||||
.map(|(t, p)| {
|
||||
let mut para = wordcraft_doc::Paragraph::with_text(t, Default::default());
|
||||
para.props = p;
|
||||
wordcraft_doc::para_block(para)
|
||||
})
|
||||
.collect();
|
||||
let l = lay(&d);
|
||||
[0, 1, 2].map(|i| l.caret(&Pos::body(i, 0)).unwrap().top)
|
||||
};
|
||||
let base = tops([plain.clone(), plain.clone(), plain.clone()]);
|
||||
let line = base[1] - base[0];
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.05;
|
||||
// Space after: 14 pt.
|
||||
let t = tops([plain.clone(), auto(false, true), plain.clone()]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
// Space before: 14 pt, but none for the document's first paragraph.
|
||||
let t = tops([auto(true, false), auto(true, false), plain.clone()]);
|
||||
assert!(near(t[0], base[0]), "no automatic space at the top of the document");
|
||||
assert!(near(t[1] - t[0], line + 14.0), "{t:?}");
|
||||
// Two automatic spaces meet: 14 pt, not 28; an automatic one beside 10 pt: 14.
|
||||
let t = tops([plain.clone(), auto(true, true), auto(true, true)]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
let ten = ParaProps { space_before: Some(10.0), ..plain.clone() };
|
||||
let t = tops([plain.clone(), auto(false, true), ten]);
|
||||
assert!(near(t[2] - t[1], line + 14.0), "{t:?}");
|
||||
// Explicitly off: the measured spacing again.
|
||||
let off = ParaProps { space_after_auto: Some(false), ..auto(false, false) };
|
||||
let t = tops([plain.clone(), off, plain.clone()]);
|
||||
assert!(near(t[2] - t[1], line + 5.0), "{t:?}");
|
||||
}
|
||||
|
||||
/// In a table cell automatic spacing drops at the cell's top and bottom (Word for Windows).
|
||||
#[test]
|
||||
fn automatic_paragraph_spacing_drops_at_cell_edges() {
|
||||
let cell_tops = |auto: bool| {
|
||||
let mut d = Document::from_text("Above");
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = ["SECOND", "THIRD"]
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(s, Default::default());
|
||||
p.props = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
space_before_auto: auto.then_some(true),
|
||||
space_after_auto: auto.then_some(true),
|
||||
..Default::default()
|
||||
};
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Para(wordcraft_doc::Paragraph::with_text("Below", Default::default())))
|
||||
.unwrap();
|
||||
let l = lay(&d);
|
||||
let in_cell = |i: u32| l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, i]), off: 0 }).unwrap().top;
|
||||
(in_cell(0), in_cell(1), l.caret(&Pos::body(2, 0)).unwrap().top)
|
||||
};
|
||||
let (a0, a1, a_below) = cell_tops(true);
|
||||
let (p0, p1, p_below) = cell_tops(false);
|
||||
assert!((a0 - p0).abs() < 0.05, "no automatic space at the top of the cell");
|
||||
assert!((a1 - a0 - (p1 - p0) - 14.0).abs() < 0.05, "14 pt between the cell's paragraphs");
|
||||
assert!((a_below - a1 - (p_below - p1)).abs() < 0.05, "none after the cell's last paragraph");
|
||||
}
|
||||
|
||||
/// Automatic spacing inside a list, measured in Word for Windows (`plan/word` R17, 16 cases): none
|
||||
/// between two list items, of one list or two, at any level, whichever item asks for it; the first
|
||||
/// item keeps its automatic space before and the last its space after, a paragraph with its list
|
||||
/// switched off in between keeps both, and measured spacing stays. NIH's bulleted IND reports
|
||||
/// (each item automatic before and after) were 14 pt apart instead of touching.
|
||||
#[test]
|
||||
fn automatic_spacing_drops_between_list_items() {
|
||||
use wordcraft_doc::props::NumRef;
|
||||
let auto = ParaProps {
|
||||
space_before: Some(5.0),
|
||||
space_after: Some(5.0),
|
||||
space_before_auto: Some(true),
|
||||
space_after_auto: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = ParaProps { space_before: Some(0.0), space_after: Some(0.0), ..Default::default() };
|
||||
let tops = |items: [ParaProps; 3], in_cell: bool| {
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let other = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let fix = |p: ParaProps| ParaProps {
|
||||
numbering: p.numbering.map(|n| NumRef {
|
||||
num: match n.num {
|
||||
0 => 0,
|
||||
1 => num,
|
||||
_ => other,
|
||||
},
|
||||
..n
|
||||
}),
|
||||
..p
|
||||
};
|
||||
let paras: Vec<_> = std::iter::once(plain.clone())
|
||||
.chain(items.map(fix))
|
||||
.chain(std::iter::once(plain.clone()))
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let mut para = wordcraft_doc::Paragraph::with_text(&format!("P{i}"), Default::default());
|
||||
para.props = p;
|
||||
wordcraft_doc::para_block(para)
|
||||
})
|
||||
.collect();
|
||||
if in_cell {
|
||||
let mut t = Table::new(1, 1, 300.0);
|
||||
t.rows[0].cells[0].blocks = paras;
|
||||
d.body = vec![wordcraft_doc::Block::Table(t).into()];
|
||||
} else {
|
||||
d.body = paras;
|
||||
}
|
||||
let at = |i: u32| if in_cell { Pos { story: StoryRef::Body, path: Path(vec![0, 0, 0, i]), off: 0 } } else { Pos::body(i as usize, 0) };
|
||||
let l = lay(&d);
|
||||
[0, 1, 2, 3, 4].map(|i| l.caret(&at(i)).unwrap().top)
|
||||
};
|
||||
let item = |num: u32, level: u8, p: &ParaProps| ParaProps { numbering: Some(NumRef { num, level }), ..p.clone() };
|
||||
for in_cell in [false, true] {
|
||||
let base = tops([plain.clone(), plain.clone(), plain.clone()], in_cell);
|
||||
let line = base[1] - base[0];
|
||||
let gaps = |t: [f32; 5]| [1, 2, 3, 4].map(|i| ((t[i] - t[i - 1] - line) * 10.0).round() / 10.0);
|
||||
let one = item(1, 0, &auto);
|
||||
let cases = [
|
||||
("one list", [one.clone(), one.clone(), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("two lists", [one.clone(), item(2, 0, &auto), item(2, 0, &auto)], [14.0, 0.0, 0.0, 14.0]),
|
||||
("two levels", [one.clone(), item(1, 1, &auto), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("a plain item between", [one.clone(), item(1, 0, &plain), one.clone()], [14.0, 0.0, 0.0, 14.0]),
|
||||
("list off between", [one.clone(), item(0, 0, &auto), one.clone()], [14.0, 14.0, 14.0, 14.0]),
|
||||
("no list", [auto.clone(), auto.clone(), auto.clone()], [14.0, 14.0, 14.0, 14.0]),
|
||||
];
|
||||
for (what, items, want) in cases {
|
||||
assert_eq!(gaps(tops(items, in_cell)), want, "{what} (in a cell: {in_cell})");
|
||||
}
|
||||
let fourteen = ParaProps { space_before: Some(14.0), ..plain.clone() };
|
||||
assert_eq!(
|
||||
gaps(tops([item(1, 0, &fourteen), item(1, 0, &fourteen), item(1, 0, &fourteen)], in_cell)),
|
||||
[14.0, 14.0, 14.0, 0.0],
|
||||
"measured spacing stays"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,3 +133,48 @@ fn hostile_link_chains_stay_bounded() {
|
|||
assert_eq!(lines(&l, id).len(), 1, "box {id} shows its own text");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
|
||||
// #743: a cover page's two-line box with no margins (`lIns="0"`…) is just tall enough for
|
||||
// its text; with Word's default margins its second line would be cut.
|
||||
let two = |h: f32, body: TextBody| {
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, h, body);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let shown: usize = lines(&l, id).iter().map(|(.., l0, l1)| l1 - l0).sum();
|
||||
(shown, lines(&l, id).first().map(|(x, y, ..)| (*x, *y)), frame_of(&l, id))
|
||||
};
|
||||
// How deep the two lines reach without margins, from a box with room to spare.
|
||||
let zero = TextBody { inset: Some([0; 4]), ..Default::default() };
|
||||
let mut d = Document::from_text("x");
|
||||
let id = text_box(&mut d, 0, "One", 144.0, 400.0, zero);
|
||||
d.parts.get_mut(&id).unwrap().blocks =
|
||||
vec![para_block(Paragraph::with_text("One", Default::default())), para_block(Paragraph::with_text("Two", Default::default()))];
|
||||
let l = lay(&d);
|
||||
let r = frame_of(&l, id);
|
||||
let need = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(s), para, l0, l1, y, .. } if *s == id => {
|
||||
let (first, last) = (para.lines.get(*l0)?, para.lines.get(l1.checked_sub(1)?)?);
|
||||
Some(y + last.top + last.height - first.top - r.y)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0f32, f32::max);
|
||||
assert!(need > 10.0, "{need}");
|
||||
let (shown, at, r) = two(need + 1.0, zero);
|
||||
assert_eq!(shown, 2, "both lines fit without margins");
|
||||
let (x, y) = at.unwrap();
|
||||
assert!((x - r.x).abs() < 0.5 && (y - r.y).abs() < 0.5, "text starts at the box's corner: {x},{y} in {r:?}");
|
||||
assert_eq!(two(need + 1.0, TextBody::default()).0, 1, "Word's default margins cut the second line");
|
||||
let (shown, ..) = two(need / 2.0, TextBody { auto_fit: true, ..Default::default() });
|
||||
assert_eq!(shown, 2, "a box that resizes to fit its text shows it all");
|
||||
// Hostile margins wider than the box still lay out.
|
||||
let (shown, ..) = two(20.0, TextBody { inset: Some([u32::MAX; 4]), ..Default::default() });
|
||||
assert!(shown >= 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use wordcraft_doc::props::{TextDirection, TextVert, VAlign};
|
|||
use wordcraft_doc::{Block, Blocks, Path, StoryRef, TextBody};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::{BOX_INSET_X, BOX_INSET_Y, Ctx, Placed, fit_box, layout_box};
|
||||
use crate::{Ctx, Placed, fit_box, layout_box};
|
||||
|
||||
/// Linked text boxes for one layout, worked out when a linked box is first laid out.
|
||||
#[derive(Default)]
|
||||
|
|
@ -54,12 +54,14 @@ pub(crate) fn shown_story(ctx: &mut Ctx, id: u32) -> u32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs `vert`.
|
||||
fn frame(vert: TextVert, w: f32, h: f32) -> (f32, f32) {
|
||||
/// Line length and the depth lines stack in, for a `w` × `h` box whose text runs as `body`
|
||||
/// says, inside its internal margins.
|
||||
fn frame(body: &TextBody, w: f32, h: f32) -> (f32, f32) {
|
||||
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
|
||||
let (along, across) = match vert.turn() {
|
||||
TextDirection::Horizontal => (w - 2.0 * BOX_INSET_X, h - 2.0 * BOX_INSET_Y),
|
||||
_ => (h - 2.0 * BOX_INSET_Y, w - 2.0 * BOX_INSET_X),
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (along, across) = match body.vert.turn() {
|
||||
TextDirection::Horizontal => (w - l - r, h - t - b),
|
||||
_ => (h - t - b, w - l - r),
|
||||
};
|
||||
(along.max(12.0), across)
|
||||
}
|
||||
|
|
@ -97,7 +99,7 @@ fn lay(ctx: &mut Ctx, id: u32, blocks: &Blocks, len: f32, vert: TextVert, depth:
|
|||
/// share of a linked chain's, cut to what fits, aligned and turned as the box says.
|
||||
pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec<Placed> {
|
||||
let body = ctx.doc.parts.get(&id).map(|p| p.body).unwrap_or_default();
|
||||
let (len, across) = frame(body.vert, rect.w, rect.h);
|
||||
let (len, across) = frame(&body, rect.w, rect.h);
|
||||
let items = match chain_share(ctx, id, depth) {
|
||||
Some(items) => items,
|
||||
None => {
|
||||
|
|
@ -107,8 +109,9 @@ pub(crate) fn box_items(ctx: &mut Ctx, id: u32, rect: Rect, depth: usize) -> Vec
|
|||
let blocks = ctx.doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
let inner = lay(ctx, id, &blocks, len, body.vert, depth);
|
||||
ctx.boxes.leave();
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word.
|
||||
fit_box(inner, across)
|
||||
// Text that doesn't fit inside the margins is hidden, as in Word; a box that resizes
|
||||
// to fit its text shows it all (#743).
|
||||
if body.auto_fit { inner } else { fit_box(inner, across) }
|
||||
}
|
||||
};
|
||||
place(items, &body, rect, across)
|
||||
|
|
@ -124,11 +127,12 @@ fn place(mut items: Vec<Placed>, body: &TextBody, rect: Rect, across: f32) -> Ve
|
|||
VAlign::Bottom => (across - used).max(0.0),
|
||||
};
|
||||
let turn = body.vert.turn();
|
||||
let [l, t, r, b] = body.insets_pt();
|
||||
let (ox, oy) = match turn {
|
||||
TextDirection::Horizontal => (rect.x + BOX_INSET_X, rect.y + BOX_INSET_Y + off),
|
||||
TextDirection::Horizontal => (rect.x + l, rect.y + t + off),
|
||||
// Top-to-bottom lines stack from the right edge, bottom-to-top ones from the left.
|
||||
TextDirection::Down => (rect.right() - BOX_INSET_X - off, rect.y + BOX_INSET_Y),
|
||||
TextDirection::Up => (rect.x + BOX_INSET_X + off, rect.bottom() - BOX_INSET_Y),
|
||||
TextDirection::Down => (rect.right() - r - off, rect.y + t),
|
||||
TextDirection::Up => (rect.x + l + off, rect.bottom() - b),
|
||||
};
|
||||
for it in &mut items {
|
||||
it.turn(turn, ox, oy);
|
||||
|
|
@ -204,7 +208,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
|
|||
};
|
||||
let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default();
|
||||
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
|
||||
let (len, across) = frame(body.vert, w, h);
|
||||
let (len, across) = frame(&body, w, h);
|
||||
let key = (len.to_bits(), body.vert);
|
||||
let items = match layouts.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, items)) => items.clone(),
|
||||
|
|
|
|||
|
|
@ -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,44 @@ pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine,
|
|||
Rendered { width: w16 as u32, height: h16 as u32, pixels }
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
//! `docs/control-protocol.md`), `{"shot": "/abs/out.png"}` to save the window as PNG, or
|
||||
//! `{"steps": n}` to run extra frames, or `{"wait": ms}` to keep running frames for a while (for
|
||||
//! background work such as a Zotero session). The window is 1440×900 pt at 2× and opens the sample
|
||||
//! document unless the first line is `{"empty": true}`.
|
||||
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
|
||||
//! starts the way the desktop app does with nothing to open (File › Home, #766).
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{ControlRequest, Services, WordApp};
|
||||
|
|
@ -27,6 +28,9 @@ fn main() {
|
|||
};
|
||||
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
|
||||
app.integrated_titlebar = true;
|
||||
if lines.first().is_some_and(|l| l.get("startScreen").is_some()) {
|
||||
app.start_screen();
|
||||
}
|
||||
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
|
||||
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
|
||||
let mut harness =
|
||||
|
|
|
|||
|
|
@ -26,9 +26,17 @@ const EDGE_SPEED_MIN: f32 = 45.0;
|
|||
const EDGE_SPEED_MAX: f32 = 720.0;
|
||||
/// Longest frame time trusted for auto-scroll (a stall shouldn't jump the page).
|
||||
const EDGE_MAX_DT: f32 = 0.1;
|
||||
/// Page textures are kept for pages within this many viewport heights of the view; farther ones
|
||||
/// are rendered again when they come back.
|
||||
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
|
||||
|
||||
pub struct CanvasState {
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
/// Page content fingerprints of the current layout, for the texture cache (#811).
|
||||
pub(crate) page_keys: PageKeys,
|
||||
/// Whether the document has tracked formatting changes, moves and deletions, by document
|
||||
/// revision: finding out walks every paragraph, too slow for every frame (#811).
|
||||
markup_facts: std::cell::Cell<Option<((u64, u64), [bool; 3])>>,
|
||||
pub scroll_to_caret: bool,
|
||||
/// A heading navigation click aligns its page against the navigation pane, not just visible.
|
||||
pub(crate) nav_to_caret: bool,
|
||||
|
|
@ -66,13 +74,18 @@ pub struct CanvasState {
|
|||
pub focused: bool,
|
||||
pub render_ms: f64,
|
||||
pub ime_preedit: String,
|
||||
/// Document faces added to the interface fonts to draw the input method's composition (#565).
|
||||
/// Document faces added to the interface fonts so the input method's composition (#565) and a
|
||||
/// file name in the window caption can be drawn.
|
||||
pub(crate) ime_faces: Vec<std::sync::Arc<wordcraft_fonts::FontFace>>,
|
||||
pub want_focus: bool,
|
||||
pub context_issue: Option<serde_json::Value>,
|
||||
pub context_synonyms: Option<serde_json::Value>,
|
||||
/// The right-click context menu is open (the mini toolbar stands down while it is).
|
||||
pub context_menu_open: bool,
|
||||
/// Shift+F10 (or `ui.contextMenu`) asked for the context menu at the caret (#821).
|
||||
pub context_menu_from_keys: bool,
|
||||
/// Where the context menu stands: the right-click point, or below the caret.
|
||||
pub context_menu_at: Option<Pos2>,
|
||||
/// Screen rect of the selection's first line, for the floating mini toolbar.
|
||||
pub mini_anchor: Option<Rect>,
|
||||
/// A picture, shape or text box being dragged by its frame.
|
||||
|
|
@ -120,6 +133,8 @@ pub struct CanvasState {
|
|||
pub(crate) link_from: Option<wordcraft_doc::Pos>,
|
||||
/// What screen readers were last told about the document (#488).
|
||||
pub(crate) a11y: crate::a11y::A11yCache,
|
||||
/// A press on a table's selection area or move handle, held (`table_mouse`).
|
||||
pub(crate) table_drag: Option<crate::table_mouse::TableDrag>,
|
||||
}
|
||||
|
||||
impl CanvasState {
|
||||
|
|
@ -127,12 +142,20 @@ impl CanvasState {
|
|||
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 {
|
||||
self.textures.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
fn default() -> Self {
|
||||
CanvasState {
|
||||
textures: HashMap::new(),
|
||||
page_keys: PageKeys::default(),
|
||||
markup_facts: std::cell::Cell::new(None),
|
||||
scroll_to_caret: true,
|
||||
nav_to_caret: false,
|
||||
read_wheel_accum: 0.0,
|
||||
|
|
@ -165,6 +188,8 @@ impl Default for CanvasState {
|
|||
erasing: None,
|
||||
ink_ui: Default::default(),
|
||||
context_menu_open: false,
|
||||
context_menu_from_keys: false,
|
||||
context_menu_at: None,
|
||||
mini_anchor: None,
|
||||
pasted: false,
|
||||
swallow_text: false,
|
||||
|
|
@ -184,6 +209,7 @@ impl Default for CanvasState {
|
|||
table_resize: None,
|
||||
link_from: None,
|
||||
a11y: Default::default(),
|
||||
table_drag: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -284,11 +310,23 @@ pub fn markup_width(app: &WordApp) -> f32 {
|
|||
// Word shows comments either in balloons (contextual) or in the Comments pane (list); Track
|
||||
// Changes Options can put every revision inline (`Session::balloons_shown`).
|
||||
let s = &app.session;
|
||||
if !s.balloons_shown() {
|
||||
return 0.0;
|
||||
}
|
||||
let m = &s.prefs.markup;
|
||||
let comments = m.comments && !s.doc.comments.is_empty();
|
||||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&s.doc);
|
||||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&s.doc);
|
||||
let deletions = s.revisions_in_balloons() && wordcraft_engine::cmd::review::has_deletions(&s.doc);
|
||||
let [has_formats, has_moves, has_deletions] = match app.canvas.markup_facts.get() {
|
||||
Some((rev, facts)) if rev == (s.document_id(), s.rev()) => facts,
|
||||
_ => {
|
||||
use wordcraft_engine::cmd::{moves, review};
|
||||
let facts = [review::has_format_changes(&s.doc), moves::has_moves(&s.doc), review::has_deletions(&s.doc)];
|
||||
app.canvas.markup_facts.set(Some(((s.document_id(), s.rev()), facts)));
|
||||
facts
|
||||
}
|
||||
};
|
||||
let formats = m.formatting && has_formats;
|
||||
let moves = m.insertions_deletions && has_moves;
|
||||
let deletions = s.revisions_in_balloons() && has_deletions;
|
||||
if s.balloons_shown() && (comments || formats || moves || deletions) { 216.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
|
|
@ -512,15 +550,24 @@ pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
|
|||
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's content for the texture cache.
|
||||
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
||||
/// Fingerprint of everything outside the layout that changes how pages render: the view's
|
||||
/// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media.
|
||||
/// Taken once per frame.
|
||||
fn view_key(app: &WordApp, dim_body: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
scale_px.to_bits().hash(&mut h);
|
||||
let v = &app.session.view;
|
||||
(v.marks, app.session.prefs.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||||
app.session.prefs.markup.hash(&mut h);
|
||||
dim_body.hash(&mut h);
|
||||
format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h);
|
||||
app.session.doc.media.len().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's laid-out content. A layout never changes once made, so this is
|
||||
/// taken once per page and layout ([`PageKeys`]), not every frame.
|
||||
fn page_content_key(page: &Page) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(page.w.to_bits(), page.h.to_bits()).hash(&mut h);
|
||||
for list in [&page.items, &page.header, &page.footer] {
|
||||
list.len().hash(&mut h);
|
||||
|
|
@ -545,7 +592,44 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
|||
}
|
||||
}
|
||||
}
|
||||
app.session.doc.media.len().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The content fingerprints of one layout's pages, taken when a page is first needed (#811).
|
||||
/// Hashing every item of every page each frame cost tens of milliseconds a frame in a long
|
||||
/// table (thousands of cell borders per page), idle or scrolling.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PageKeys {
|
||||
/// The layout these are for; held so its address can't be reused by another one.
|
||||
layout: Option<std::sync::Arc<DocLayout>>,
|
||||
keys: Vec<Option<u64>>,
|
||||
/// Pages fingerprinted since the layout last changed.
|
||||
pub(crate) hashed: usize,
|
||||
}
|
||||
|
||||
impl PageKeys {
|
||||
fn content(&mut self, layout: &std::sync::Arc<DocLayout>, i: usize) -> u64 {
|
||||
if !self.layout.as_ref().is_some_and(|l| std::sync::Arc::ptr_eq(l, layout)) {
|
||||
self.layout = Some(layout.clone());
|
||||
self.keys = vec![None; layout.pages.len()];
|
||||
self.hashed = 0;
|
||||
}
|
||||
if let Some(Some(k)) = self.keys.get(i) {
|
||||
return *k;
|
||||
}
|
||||
let k = layout.pages.get(i).map_or(0, page_content_key);
|
||||
if let Some(slot) = self.keys.get_mut(i) {
|
||||
*slot = Some(k);
|
||||
self.hashed += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture cache key of page `i`: its content, the view, and the raster scale.
|
||||
fn page_key(app: &mut WordApp, layout: &std::sync::Arc<DocLayout>, i: usize, view: u64, scale_px: f32) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(view, scale_px.to_bits(), app.canvas.page_keys.content(layout, i)).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
|
|
@ -695,7 +779,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
app.canvas.scroll_to_caret = true;
|
||||
}
|
||||
}
|
||||
let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint);
|
||||
// Where the selection really ends, also when it took whole table cells.
|
||||
let caret = layout.caret_on(&app.session.sel_ends().focus, app.session.page_hint);
|
||||
// Editing a header/footer (or a note) dims the body; once per frame, for every page.
|
||||
let dim_body = dims_body(app, &layout);
|
||||
if let Some(c) = caret {
|
||||
|
|
@ -826,22 +911,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
let t0 = crate::now_ms();
|
||||
let mut rects = Vec::with_capacity(geo.rects.len());
|
||||
let content_origin = ui.min_rect().min;
|
||||
let view = view_key(app, dim_body);
|
||||
for (i, pr) in geo.rects.iter().enumerate() {
|
||||
let sr = pr.translate(content_origin.to_vec2());
|
||||
let Some(page) = layout.pages.get(i) else { continue };
|
||||
let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height()));
|
||||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
// Most reading documents have many columns; skip expensive page hashing for offscreen
|
||||
// pages. We still emit their rectangles for hit testing and the control channel.
|
||||
if !sr.intersects(ui.clip_rect().expand(200.0)) {
|
||||
rects.push(sr);
|
||||
continue;
|
||||
}
|
||||
let key = page_key(app, page, scale_px, dim_body);
|
||||
let mut fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// Hide White Space shows only a band of the page; everything below paints into it.
|
||||
let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot));
|
||||
let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0));
|
||||
// Only pages on screen or with a texture need their key (#811).
|
||||
let cached = app.canvas.textures.contains_key(&i);
|
||||
let key = if visible || cached { page_key(app, &layout, i, view, scale_px) } else { 0 };
|
||||
let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// 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) {
|
||||
|
|
@ -964,9 +1046,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
if rendered > 0 {
|
||||
app.canvas.render_ms = crate::now_ms() - t0;
|
||||
}
|
||||
// Drop textures of pages that no longer exist.
|
||||
// Drop textures of pages that no longer exist, and of pages scrolled well out of view
|
||||
// (#811): scrolling through a long document kept every page it had shown in memory.
|
||||
let n = layout.pages.len();
|
||||
app.canvas.textures.retain(|k, _| *k < n);
|
||||
let clip = ui.clip_rect();
|
||||
let near = clip.expand2(vec2(clip.width(), clip.height() * TEXTURE_KEEP_SCREENS));
|
||||
app.canvas.textures.retain(|k, _| *k < n && geo.rects.get(*k).is_some_and(|r| r.translate(content_origin.to_vec2()).intersects(near)));
|
||||
app.canvas.page_rects = rects.clone();
|
||||
balloons(app, ui, &painter, &rects, &layout, geo.scale);
|
||||
// Find › Reading Highlight.
|
||||
|
|
@ -980,31 +1065,34 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Selection (a selected object shows its frame instead).
|
||||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||||
// A column selection highlights each row's piece.
|
||||
// Selection (a selected object shows its frame instead): whole table cells shade the
|
||||
// cells (an empty one too), a column selection each row's piece.
|
||||
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
|
||||
Vec::new()
|
||||
} else if let Some(range) = app.session.cell_range() {
|
||||
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
|
||||
} else if !app.session.sel.is_collapsed() {
|
||||
let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() {
|
||||
Some(segs) => segs.to_vec(),
|
||||
None => vec![app.session.sel.ordered()],
|
||||
};
|
||||
let mut first: Option<Rect> = None;
|
||||
for (a, b) in &pieces {
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
}
|
||||
}
|
||||
pieces.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut first: Option<Rect> = None;
|
||||
for (pi, r) in selected {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||||
first = Some(sr);
|
||||
}
|
||||
}
|
||||
app.canvas.mini_anchor = first;
|
||||
} else {
|
||||
app.canvas.mini_anchor = None;
|
||||
}
|
||||
app.canvas.mini_anchor = first;
|
||||
// Content controls: the frame of the one the caret is in (all of them in Design Mode).
|
||||
crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
// Read Aloud: the sentence being spoken.
|
||||
|
|
@ -1113,6 +1201,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
crate::ink::paint(app, &painter, &rects, &layout, geo.scale);
|
||||
crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
crate::table_resize::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||||
(resp, rects)
|
||||
});
|
||||
app.canvas.scroll_offset = out.state.offset;
|
||||
|
|
@ -1161,6 +1250,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
}
|
||||
// A click on a check box ticks it; on a list's button opens the list.
|
||||
crate::content_controls::clicks(app, &resp);
|
||||
// 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()
|
||||
&& 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)
|
||||
{
|
||||
let at = wordcraft_geom::Point::new(x, y);
|
||||
let on_cells =
|
||||
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range).iter().any(|(pi, r)| *pi == page && r.contains(at));
|
||||
if !on_cells && let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) {
|
||||
app.session.sel = wordcraft_engine::Selection::caret(pos);
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
|
|
@ -1192,21 +1295,48 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|||
if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() {
|
||||
app.session.sel = wordcraft_engine::Selection::caret(pos);
|
||||
}
|
||||
app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null());
|
||||
app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned());
|
||||
context_suggestions(app);
|
||||
}
|
||||
}
|
||||
resp.context_menu(|ui| context_menu(app, ui));
|
||||
// Stand down while the context menu owns the pointer so the two never double up;
|
||||
// `context_menu_opened` also covers the click that dismisses the menu.
|
||||
app.canvas.context_menu_open = resp.context_menu_opened();
|
||||
// Shift+F10 opens the same menu below the caret, for the selection or the word there (#821).
|
||||
let from_keys = std::mem::take(&mut app.canvas.context_menu_from_keys);
|
||||
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() {
|
||||
app.canvas.context_menu_at = resp.interact_pointer_pos();
|
||||
}
|
||||
let open = if resp.secondary_clicked() || from_keys {
|
||||
Some(egui::SetOpenCommand::Bool(true))
|
||||
} else if resp.clicked() {
|
||||
Some(egui::SetOpenCommand::Bool(false))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let menu = egui::Popup::menu(&resp).open_memory(open);
|
||||
let menu = match app.canvas.context_menu_at {
|
||||
Some(p) => menu.at_position(p),
|
||||
None => menu.at_pointer_fixed(),
|
||||
};
|
||||
menu.show(|ui| context_menu(app, ui));
|
||||
// Stand down while the context menu owns the pointer so the two never double up; the open
|
||||
// state also covers the click that dismisses the menu.
|
||||
app.canvas.context_menu_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp));
|
||||
if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if resp.hovered() || app.canvas.obj_drag.is_some() {
|
||||
let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
|
||||
ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text));
|
||||
let latest = ui.input(|i| i.pointer.latest_pos());
|
||||
let over_object = latest.and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
|
||||
// The selection bar: Word shows a right-pointing arrow there; egui has none, so the
|
||||
// plain arrow stands in (#856).
|
||||
let in_bar = app.session.sel.focus.story == StoryRef::Body
|
||||
&& (app.canvas.unit_drag.as_ref().is_some_and(|d| d.unit == "line")
|
||||
|| latest.and_then(|p| page_at(&rects, &layout, geo.scale, p)).is_some_and(|(pg, x, y)| layout.selection_bar_at(pg, x, y).is_some()));
|
||||
let fallback = if in_bar { egui::CursorIcon::Default } else { egui::CursorIcon::Text };
|
||||
ui.ctx().set_cursor_icon(over_object.unwrap_or(fallback));
|
||||
}
|
||||
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 { .. })) {
|
||||
|
|
@ -1623,6 +1753,10 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
if crate::table_resize::pointer(app, ui, resp, rects, layout, scale) {
|
||||
return;
|
||||
}
|
||||
// A press on a table's selection area, insert button or move handle.
|
||||
if crate::table_mouse::pointer(app, ui, resp, rects, layout, scale) {
|
||||
return;
|
||||
}
|
||||
let Some(p) = pointer else { return };
|
||||
// While drag-selecting, the pointer may sit past the viewport's edge (or the window's):
|
||||
// hit-test where it would be inside the view, so as the edge auto-scroll moves the content the
|
||||
|
|
@ -1741,6 +1875,42 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
if app.session.math.is_some() {
|
||||
let _ = app.run("equation.exit", json!({}));
|
||||
}
|
||||
// The selection bar (the left margin beside body text, #856): a click selects the line,
|
||||
// a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; Shift+click
|
||||
// extends by lines; dragging grows the selection by the clicked unit.
|
||||
if story == StoryRef::Body
|
||||
&& !mods.alt
|
||||
&& let Some(line) = layout.selection_bar_at(page, x, y)
|
||||
{
|
||||
app.canvas.column_drag = None;
|
||||
app.session.page_hint = page;
|
||||
let lj = serde_json::to_value(&line).unwrap_or_default();
|
||||
let (unit, start, end) = if mods.shift {
|
||||
let anchor = app.session.sel.anchor.clone();
|
||||
let aj = serde_json::to_value(&anchor).unwrap_or_default();
|
||||
let _ = app.run("select.dragUnit", json!({"unit": "line", "start": aj, "end": aj, "pos": lj}));
|
||||
("line", anchor.clone(), anchor)
|
||||
} else {
|
||||
let unit = match clicks {
|
||||
_ if mods.command => "document",
|
||||
3.. => "document",
|
||||
2 => "paragraph",
|
||||
_ => "line",
|
||||
};
|
||||
let _ = app.run("caret.set", json!({"pos": lj}));
|
||||
let id = match unit {
|
||||
"document" => "select.all",
|
||||
"paragraph" => "select.paragraph",
|
||||
_ => "select.line",
|
||||
};
|
||||
let _ = app.run(id, json!({}));
|
||||
let (start, end) = app.session.sel.ordered();
|
||||
(unit, start, end)
|
||||
};
|
||||
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
|
||||
app.canvas.dragging = true;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
|
|
@ -1799,7 +1969,13 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some(d) = app.canvas.unit_drag.as_mut()
|
||||
{
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
// Lines grow line by line wherever the pointer is across; the whole document can't grow.
|
||||
let target = match d.unit {
|
||||
"line" => layout.body_line_at(page, y),
|
||||
"document" => None,
|
||||
_ => layout.hit(page, x, y, story),
|
||||
};
|
||||
if let Some(pos) = target
|
||||
&& pos != d.last
|
||||
{
|
||||
d.last = pos.clone();
|
||||
|
|
@ -1809,10 +1985,13 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
|||
app.canvas.caret_visible_since = crate::now_ms();
|
||||
}
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
|
||||
// Through the command, so from one table cell into another the drag takes whole cells
|
||||
// (Word); straight to the session, as the app's `run` would scroll on every frame.
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& pos != app.session.sel.focus
|
||||
&& pos != app.session.sel_ends().focus
|
||||
{
|
||||
app.session.sel.focus = pos;
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
let _ = app.session.run("caret.set", &json!({"pos": pj, "extend": true}));
|
||||
app.session.page_hint = page;
|
||||
app.canvas.caret_visible_since = crate::now_ms();
|
||||
}
|
||||
|
|
@ -1889,14 +2068,19 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
] {
|
||||
margin_handle(app, ui, edge, zone, |p| (p.x - x0) / scale, §);
|
||||
}
|
||||
// Indent markers for the caret's paragraph (relative to its column).
|
||||
let col_x = page.body.x;
|
||||
// In a table, its column borders; registered before the indent markers, which keep the
|
||||
// pointer where they overlap.
|
||||
crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale);
|
||||
// Indent markers for the caret's paragraph (relative to its column, or in a table to the
|
||||
// cell's text).
|
||||
let (col_x, w) = crate::table_ruler::text_area(app, layout, pi).unwrap_or((page.body.x, page.body.w));
|
||||
// Where the paragraph starts, from the page's start edge (the right edge when right-to-left).
|
||||
let start = if rtl { page.w - (col_x + w) } else { col_x };
|
||||
if let Some(rp) = rp {
|
||||
// Indents and tabs are measured from the paragraph's start edge: the right margin of a
|
||||
// right-to-left paragraph, where the start-indent markers sit.
|
||||
let w = page.body.w;
|
||||
let px = |s: f32| sx(origin + s);
|
||||
let dist_at = |x: f32| s_at(x) - origin;
|
||||
let px = |s: f32| sx(start + s);
|
||||
let dist_at = |x: f32| s_at(x) - start;
|
||||
let first = px(rp.indent_left + rp.indent_first);
|
||||
let left = px(rp.indent_left);
|
||||
let right = px(w - rp.indent_right);
|
||||
|
|
@ -1943,7 +2127,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = dist_at(pp.x).clamp(-col_x, page.body.w - 18.0);
|
||||
let pt = dist_at(pp.x).clamp(-col_x, (w - 18.0).max(-col_x));
|
||||
let snapped = (pt / 4.5).round() * 4.5;
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
|
|
@ -1966,7 +2150,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = page.body.w - dist_at(pp.x);
|
||||
let pt = w - dist_at(pp.x);
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
|
|
@ -2008,6 +2192,8 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
|||
break;
|
||||
}
|
||||
}
|
||||
// In a table, its rows' bottom borders.
|
||||
crate::table_ruler::rows(app, ui, layout, pi, vbar, y0, scale);
|
||||
}
|
||||
|
||||
/// One mark drawn along a horizontal ruler.
|
||||
|
|
@ -2190,6 +2376,13 @@ fn dims_body(app: &WordApp, layout: &DocLayout) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// The spelling suggestions and synonyms the context menu offers for the selection or the word
|
||||
/// at the caret.
|
||||
fn context_suggestions(app: &mut WordApp) {
|
||||
app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null());
|
||||
app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned());
|
||||
}
|
||||
|
||||
/// Caret position on screen.
|
||||
pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> {
|
||||
let l = app.session.layout();
|
||||
|
|
@ -2343,19 +2536,76 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
|||
ui.menu_button(tl!("Wrap Text"), |ui| crate::ribbon::wrap_text_items(ui, app));
|
||||
}
|
||||
if app.session.sel.focus.path.cell().is_some() {
|
||||
// The table's items follow what is selected, as in Word (2019, Windows).
|
||||
let pick = table_pick(&app.session);
|
||||
ui.separator();
|
||||
ui.menu_button(tl!("Insert"), |ui| {
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||||
});
|
||||
ui.menu_button(tl!("Delete"), |ui| {
|
||||
item(ui, app, "Delete Rows", "table.deleteRow", json!({}));
|
||||
item(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
|
||||
item(ui, app, "Delete Table", "table.deleteTable", json!({}));
|
||||
});
|
||||
item(ui, app, "Merge Cells", "table.merge", json!({}));
|
||||
match pick {
|
||||
TablePick::Cell | TablePick::Cells => item(ui, app, "Delete Cells", "table.deleteCells", json!({})),
|
||||
TablePick::Rows => item(ui, app, "Delete Rows", "table.deleteRow", json!({})),
|
||||
TablePick::Columns => item(ui, app, "Delete Columns", "table.deleteColumn", json!({})),
|
||||
TablePick::Table => item(ui, app, "Delete Table", "table.deleteTable", json!({})),
|
||||
}
|
||||
if pick == TablePick::Cell {
|
||||
item(ui, app, "Split Cells", "table.split", json!({}));
|
||||
}
|
||||
if matches!(pick, TablePick::Cells | TablePick::Rows | TablePick::Columns) {
|
||||
item(ui, app, "Merge Cells", "table.merge", json!({}));
|
||||
}
|
||||
if matches!(pick, TablePick::Rows | TablePick::Columns | TablePick::Table) {
|
||||
item(ui, app, "Distribute Rows", "table.distributeRows", json!({}));
|
||||
item(ui, app, "Distribute Columns", "table.distributeColumns", json!({}));
|
||||
}
|
||||
if pick == TablePick::Table {
|
||||
ui.menu_button(tl!("AutoFit"), |ui| {
|
||||
item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
});
|
||||
}
|
||||
item(ui, app, "Text Direction", "table.textDirection", json!({}));
|
||||
if pick == TablePick::Table {
|
||||
item(ui, app, "Insert Caption…", "references.caption", json!({}));
|
||||
}
|
||||
item(ui, app, "Table Properties…", "table.properties", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// What of a table the selection is: Word's context menu offers different table items for each.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TablePick {
|
||||
/// The caret or text in one cell, or that one cell.
|
||||
Cell,
|
||||
/// Cells of more than one row or column, but not whole rows or columns.
|
||||
Cells,
|
||||
Rows,
|
||||
Columns,
|
||||
Table,
|
||||
}
|
||||
|
||||
fn table_pick(s: &wordcraft_engine::Session) -> TablePick {
|
||||
if let Some(r) = s.cell_range()
|
||||
&& let Some(t) = s.doc.table(r.story, &r.table)
|
||||
{
|
||||
let all_rows = r.rows == (0, t.rows.len().saturating_sub(1));
|
||||
let all_cols = r.cols == (0, t.cols().saturating_sub(1));
|
||||
return match (all_rows, all_cols) {
|
||||
(true, true) => TablePick::Table,
|
||||
(true, false) => TablePick::Columns,
|
||||
(false, true) => TablePick::Rows,
|
||||
_ if r.cells(t).len() > 1 => TablePick::Cells,
|
||||
_ => TablePick::Cell,
|
||||
};
|
||||
}
|
||||
// Text running from one cell into another of the same table.
|
||||
match (s.sel.anchor.path.cell(), s.sel.focus.path.cell()) {
|
||||
(Some((ta, ra, ca)), Some((tb, rb, cb))) if ta == tb && (ra, ca) != (rb, cb) => TablePick::Cells,
|
||||
_ => TablePick::Cell,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2402,6 +2652,7 @@ fn is_followable_link(url: &str) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::table_resize::tests::{H, cell, screen, steps, table_window};
|
||||
|
||||
#[test]
|
||||
fn reading_animation_eases_then_lands_exactly_on_column_start() {
|
||||
|
|
@ -2575,6 +2826,104 @@ mod tests {
|
|||
assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6)));
|
||||
}
|
||||
|
||||
/// #856: the left margin beside body text is Word's selection bar. A click selects the line,
|
||||
/// a double-click the paragraph, a triple-click (or Ctrl/⌘+click) the document; dragging
|
||||
/// selects whole lines and Shift+click extends by lines.
|
||||
#[test]
|
||||
fn selection_bar_clicks_select_lines() {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": format!("{}\rSecond para\rThird para", long.trim_end())})).unwrap();
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.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();
|
||||
}
|
||||
// A point in the left margin beside the line `li` lines below the one holding `pos`.
|
||||
let margin = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: Pos, li: f32| {
|
||||
let c = h.state_mut().session.layout().caret(&pos).expect("caret");
|
||||
page_to_screen(h.state_mut(), c.page, 30.0, c.top + c.height * (li + 0.5)).expect("on screen")
|
||||
};
|
||||
let line_start = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
|
||||
let (rects, scale) = (h.state().canvas.page_rects.clone(), h.state().canvas.scale);
|
||||
let layout = h.state_mut().session.layout();
|
||||
let (pg, x, y) = page_at(&rects, &layout, scale, at).expect("page");
|
||||
layout.selection_bar_at(pg, x, y).expect("in the selection bar")
|
||||
};
|
||||
let span = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: &Pos| h.state_mut().session.layout().line_span(pos, 0).expect("line");
|
||||
let button =
|
||||
|at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let clicks_drag = |h: &mut egui_kittest::Harness<'_, WordApp>, from: Pos2, to: Pos2, clicks: usize| {
|
||||
h.event(Event::PointerMoved(from));
|
||||
for i in 0..clicks {
|
||||
h.event(button(from, true));
|
||||
h.step();
|
||||
if i + 1 < clicks {
|
||||
h.event(button(from, false));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
for k in 1..=4 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(button(to, false));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
let pause = |h: &mut egui_kittest::Harness<'_, WordApp>| {
|
||||
for _ in 0..40 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
// Click beside the second line: that whole line is selected, not a caret at its start.
|
||||
let at = margin(&mut h, Pos::body(0, 0), 1.0);
|
||||
let l2 = line_start(&mut h, at);
|
||||
assert!(l2.off > 0 && l2.path == wordcraft_doc::Path::top(0));
|
||||
clicks_drag(&mut h, at, at, 1);
|
||||
let (_, l2_end, _) = span(&mut h, &l2);
|
||||
assert_eq!(h.state().session.sel.ordered(), (l2.clone(), l2_end.clone()), "the line");
|
||||
// Double-click: the paragraph, mark included.
|
||||
pause(&mut h);
|
||||
clicks_drag(&mut h, at, at, 2);
|
||||
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(1, 0)), "the paragraph");
|
||||
// Triple-click: the whole document.
|
||||
pause(&mut h);
|
||||
clicks_drag(&mut h, at, at, 3);
|
||||
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 10)), "everything");
|
||||
// Drag from beside the first line down past the third: three whole lines.
|
||||
pause(&mut h);
|
||||
let (from, to) = (margin(&mut h, Pos::body(0, 0), 0.0), margin(&mut h, Pos::body(0, 0), 2.0));
|
||||
let l3 = line_start(&mut h, to);
|
||||
clicks_drag(&mut h, from, to, 1);
|
||||
let (_, l3_end, _) = span(&mut h, &l3);
|
||||
let sel = h.state().session.sel.clone();
|
||||
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), l3_end), "three lines");
|
||||
// Shift+click beside "Second para" extends by lines, to that paragraph's mark.
|
||||
pause(&mut h);
|
||||
let second = margin(&mut h, Pos::body(1, 0), 0.0);
|
||||
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.step();
|
||||
clicks_drag(&mut h, second, second, 1);
|
||||
h.event(Event::ModifiersChanged(Modifiers::default()));
|
||||
h.step();
|
||||
let sel = h.state().session.sel.clone();
|
||||
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), Pos::body(2, 0)), "extended by lines");
|
||||
// Hovering the bar shows the arrow, not the I-beam.
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
|
||||
}
|
||||
|
||||
/// #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]
|
||||
|
|
@ -2735,6 +3084,112 @@ mod tests {
|
|||
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll");
|
||||
}
|
||||
|
||||
/// 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));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
steps(h, 2);
|
||||
}
|
||||
|
||||
/// Where the open context menu's entry holding `label` is (an entry's label also has its
|
||||
/// shortcut or submenu arrow, and the ribbon above the page may show the same name).
|
||||
fn menu_entry(h: &H, label: &str) -> Option<Pos2> {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let top = h.state().canvas.page_rects.first().map_or(0.0, |r| r.min.y);
|
||||
h.query_all_by_label_contains(label).map(|n| n.rect()).find(|r| r.min.y >= top).map(|r| r.center())
|
||||
}
|
||||
|
||||
/// Picks `path` in the open context menu with the mouse: over each submenu on the way, then a
|
||||
/// click on the last entry.
|
||||
fn pick(h: &mut H, path: &[&str]) {
|
||||
let Some((last, submenus)) = path.split_last() else { return };
|
||||
for label in submenus {
|
||||
let at = menu_entry(h, label).expect(label);
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
steps(h, 2);
|
||||
}
|
||||
let at = menu_entry(h, last).expect(last);
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
steps(h, 2);
|
||||
}
|
||||
|
||||
/// Whether the open context menu has an entry holding each of `labels` (`true`) or none
|
||||
/// (`false`).
|
||||
fn assert_menu(h: &H, labels: &[&str], there: bool) {
|
||||
for label in labels {
|
||||
assert_eq!(menu_entry(h, label).is_some(), there, "{label} in the menu: {there}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-clicking a table cell puts the caret there, and the table's items follow what is
|
||||
/// selected, as in Word: with the caret in a cell, Delete Cells and Split Cells; with the
|
||||
/// whole table, Delete Table, Distribute, AutoFit and Insert Caption… (never Select or Cell
|
||||
/// Alignment).
|
||||
#[test]
|
||||
fn the_context_menu_in_a_table_follows_the_selection() {
|
||||
let mut h = table_window();
|
||||
let b = cell(&mut h, 1, 2);
|
||||
let at = screen(&mut h, b.x + b.w / 2.0, b.y + b.h / 2.0);
|
||||
right_click(&mut h, at);
|
||||
assert_eq!(h.state().session.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((1, 2)), "the caret moved to the cell");
|
||||
assert_menu(&h, &["Insert", "Delete Cells", "Split Cells", "Text Direction", "Table Properties…"], true);
|
||||
assert_menu(&h, &["Merge Cells", "Distribute Rows", "Delete Table", "AutoFit", "Select", "Cell Alignment"], false);
|
||||
let depth = h.state().session.undo_depth();
|
||||
pick(&mut h, &["Insert", "Insert Rows Below"]);
|
||||
assert_eq!(h.state().session.doc.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.len()), Some(3));
|
||||
assert_eq!(h.state().session.undo_depth(), depth + 1);
|
||||
h.state_mut().run("table.selectTable", json!({})).unwrap();
|
||||
right_click(&mut h, at);
|
||||
assert!(h.state().session.cell_range().is_some(), "a right-click on the selected table keeps it");
|
||||
assert_menu(&h, &["Delete Table", "Distribute Rows", "Distribute Columns", "AutoFit", "Insert Caption…", "Table Properties…"], true);
|
||||
assert_menu(&h, &["Delete Cells", "Split Cells", "Merge Cells"], false);
|
||||
pick(&mut h, &["Delete Table"]);
|
||||
assert!(h.state().session.doc.body.iter().all(|b| b.as_table().is_none()));
|
||||
}
|
||||
|
||||
/// With a column's cells selected, a right-click on one of them keeps them: the menu is a
|
||||
/// column's (Delete Columns, Merge Cells, Distribute) and its Text Direction turns just the
|
||||
/// column, in one undo step; one on a cell beside them drops them, though that cell lies
|
||||
/// between the selection's ends in the text, and the menu is a cell's again.
|
||||
#[test]
|
||||
fn a_right_click_keeps_selected_cells_only_on_them() {
|
||||
use wordcraft_doc::props::TextDirection;
|
||||
let mut h = table_window();
|
||||
h.state_mut().run("table.selectColumn", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 0))));
|
||||
let on = cell(&mut h, 1, 0);
|
||||
let at = screen(&mut h, on.x + on.w / 2.0, on.y + on.h / 2.0);
|
||||
right_click(&mut h, at);
|
||||
assert_eq!(h.state().session.cell_range().map(|r| r.cols), Some((0, 0)), "still the column");
|
||||
assert_menu(&h, &["Delete Columns", "Merge Cells", "Distribute Rows", "Distribute Columns", "Text Direction"], true);
|
||||
assert_menu(&h, &["Delete Cells", "Split Cells", "AutoFit"], false);
|
||||
let depth = h.state().session.undo_depth();
|
||||
pick(&mut h, &["Text Direction"]);
|
||||
assert_eq!(h.state().session.undo_depth(), depth + 1);
|
||||
let t = h.state().session.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
let dir = |r: usize, c: usize| t.rows[r].cells[c].props.text_direction;
|
||||
assert_eq!(
|
||||
[dir(0, 0), dir(1, 0), dir(0, 1), dir(1, 2)],
|
||||
[TextDirection::Down, TextDirection::Down, TextDirection::Horizontal, TextDirection::Horizontal]
|
||||
);
|
||||
let beside = cell(&mut h, 0, 2);
|
||||
let at = screen(&mut h, beside.x + beside.w / 2.0, beside.y + beside.h / 2.0);
|
||||
right_click(&mut h, at);
|
||||
assert!(h.state().session.cell_range().is_none(), "the column is no longer selected");
|
||||
assert_eq!(h.state().session.sel.focus.path.cell().map(|(_, r, c)| (r, c)), Some((0, 2)));
|
||||
assert_menu(&h, &["Split Cells"], true);
|
||||
assert_menu(&h, &["Delete Columns", "Merge Cells"], false);
|
||||
}
|
||||
|
||||
/// Issue #123: zoomed out, pages sit side by side in rows when the width allows; at 100% a
|
||||
/// letter page in an ordinary window stays one per row.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
|
|||
}
|
||||
}
|
||||
crate::widgets::command_menu(&resp, app, id);
|
||||
if id == "edit.undo" {
|
||||
undo_list_button(ui, app, enabled);
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::hover());
|
||||
|
|
@ -73,6 +76,85 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The Undo button's drop-down arrow (#701), which opens [`undo_list`].
|
||||
pub(crate) fn undo_list_button_id() -> egui::Id {
|
||||
egui::Id::new("qat_undo_list")
|
||||
}
|
||||
|
||||
/// Row `i` of the Undo list (0 = the newest step).
|
||||
fn undo_list_row_id(i: usize) -> egui::Id {
|
||||
undo_list_button_id().with(("row", i))
|
||||
}
|
||||
|
||||
/// The Undo list's rows, newest first: each step named as the Undo button's tooltip names it.
|
||||
pub(crate) fn undo_list_rows(app: &WordApp) -> Vec<String> {
|
||||
app.session.undo_history().into_iter().map(|l| tl!(l).to_string()).collect()
|
||||
}
|
||||
|
||||
/// The Undo list's footer: how many steps a click undoes, or Cancel while none is highlighted.
|
||||
pub(crate) fn undo_list_footer(n: usize) -> String {
|
||||
match n {
|
||||
0 => tl!("Cancel").to_string(),
|
||||
1 => tl!("Undo 1 Action").to_string(),
|
||||
n => crate::i18n::fmt(tl!("Undo {n} Actions"), &[("n", &n.to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The small arrow beside Undo: opens the list of steps to undo several at once.
|
||||
fn undo_list_button(ui: &mut Ui, app: &mut WordApp, enabled: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(12.0, 24.0), Sense::hover());
|
||||
let resp = ui.interact(r, undo_list_button_id(), if enabled { Sense::click() } else { Sense::hover() });
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(9.0, 9.0)), "dropdown", c, c);
|
||||
let resp = resp.on_hover_text(tl!("Undo History"));
|
||||
egui::Popup::menu(&resp).show(|ui| undo_list(ui, app));
|
||||
}
|
||||
|
||||
/// The Undo list (#701): recent steps, newest first. Hovering a row highlights it and every newer
|
||||
/// one; clicking it undoes them all in one go (`edit.undoTo`).
|
||||
fn undo_list(ui: &mut Ui, app: &mut WordApp) {
|
||||
const W: f32 = 210.0;
|
||||
const ROW: f32 = 20.0;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rows = undo_list_rows(app);
|
||||
ui.set_width(W);
|
||||
let (mut hovered, mut clicked) = (None, None);
|
||||
egui::ScrollArea::vertical().max_height(ROW * 18.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let rects: Vec<Rect> = (0..rows.len())
|
||||
.map(|i| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::hover());
|
||||
let resp = ui.interact(r, undo_list_row_id(i), Sense::click());
|
||||
if resp.hovered() {
|
||||
hovered = Some(i);
|
||||
}
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
for (i, (r, label)) in rects.iter().zip(&rows).enumerate() {
|
||||
if hovered.is_some_and(|h| i <= h) {
|
||||
ui.painter().rect_filled(*r, 0.0, t.selection);
|
||||
}
|
||||
// Clipped to its row: a long label never runs into the scroll bar.
|
||||
ui.painter_at(r.shrink2(vec2(6.0, 0.0))).text(pos2(r.min.x + 6.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.text);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let footer = undo_list_footer(hovered.map_or(0, |h| h + 1));
|
||||
ui.vertical_centered(|ui| ui.label(egui::RichText::new(footer).font(regular(12.0)).color(t.text)));
|
||||
if let Some(i) = clicked {
|
||||
let _ = app.run("edit.undoTo", json!({"count": i + 1}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`].
|
||||
pub(crate) fn qat_menu_button_id() -> egui::Id {
|
||||
egui::Id::new("qat_menu_button")
|
||||
|
|
@ -219,8 +301,15 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
|||
ptrack,
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "commands"}));
|
||||
if app.ui.keytips == crate::keytips::Phase::Tabs {
|
||||
app.keytip_rects.push((r, crate::keytips::SEARCH_LETTER.to_string()));
|
||||
}
|
||||
let tip = match crate::widgets::shortcut_text(app, "tools.search") {
|
||||
k if k.is_empty() => tl!("Search").to_string(),
|
||||
k => format!("{} ({k})", tl!("Search")),
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
let _ = app.run("tools.search", json!({}));
|
||||
}
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
|
|
@ -426,6 +515,63 @@ impl WordApp {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
|
||||
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
|
||||
ctx
|
||||
}
|
||||
|
||||
fn frame(ctx: &egui::Context, n: &mut u32, events: Vec<egui::Event>, a: &mut WordApp) {
|
||||
*n += 1;
|
||||
let input = egui::RawInput { time: Some(f64::from(*n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
|
||||
ctx.run_ui(input, |ui| title_bar(a, ui)).drop_without_applying_deltas();
|
||||
}
|
||||
|
||||
fn click(ctx: &egui::Context, n: &mut u32, pos: egui::Pos2, a: &mut WordApp) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(ctx, n, vec![egui::Event::PointerMoved(pos), button(true)], a);
|
||||
frame(ctx, n, vec![button(false)], a);
|
||||
}
|
||||
|
||||
/// #701: the arrow beside Undo lists the session's undo steps, newest first; clicking the third
|
||||
/// row undoes three steps at once.
|
||||
#[test]
|
||||
fn undo_list_matches_the_session_and_undoes_up_to_the_clicked_row() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello"})).unwrap();
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
a.run("format.bold", json!({})).unwrap();
|
||||
a.run("format.italic", json!({})).unwrap();
|
||||
a.run("caret.docEnd", json!({})).unwrap();
|
||||
a.run("text.insert", json!({"text": " world"})).unwrap();
|
||||
let session: Vec<String> = a.session.undo_history().into_iter().map(str::to_string).collect();
|
||||
assert_eq!(session, ["Typing", "Italic", "Bold", "Typing"]);
|
||||
assert_eq!(undo_list_rows(&a), session);
|
||||
assert_eq!(undo_list_footer(0), "Cancel");
|
||||
assert_eq!(undo_list_footer(1), "Undo 1 Action");
|
||||
assert_eq!(undo_list_footer(3), "Undo 3 Actions");
|
||||
|
||||
let (ctx, mut n) = (ctx(), 0);
|
||||
frame(&ctx, &mut n, vec![], &mut a);
|
||||
let arrow = ctx.read_response(undo_list_button_id()).unwrap();
|
||||
let popup = egui::Popup::default_response_id(&arrow);
|
||||
click(&ctx, &mut n, arrow.rect.center(), &mut a);
|
||||
assert!(egui::Popup::is_id_open(&ctx, popup), "the arrow opens the Undo list");
|
||||
frame(&ctx, &mut n, vec![], &mut a);
|
||||
for i in 0..4 {
|
||||
assert!(ctx.read_response(undo_list_row_id(i)).is_some(), "row {i} is drawn");
|
||||
}
|
||||
assert!(ctx.read_response(undo_list_row_id(4)).is_none(), "one row per step");
|
||||
let third = ctx.read_response(undo_list_row_id(2)).unwrap();
|
||||
click(&ctx, &mut n, third.rect.center(), &mut a);
|
||||
assert_eq!(a.session.undo_history(), ["Typing"], "three steps undone in one go");
|
||||
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello");
|
||||
frame(&ctx, &mut n, vec![], &mut a);
|
||||
assert!(!egui::Popup::is_id_open(&ctx, popup), "the list closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_title_bar_centres_and_clears_the_traffic_lights() {
|
||||
// The lights are centred in the bar, and content keeps the same gap after them.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//!
|
||||
//! 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.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)
|
||||
|
|
@ -119,12 +119,19 @@ 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))
|
||||
}
|
||||
"document.inspect" => match app.session.run("document.inspect", p) {
|
||||
Ok(v) => ok(v),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,25 @@ pub struct FindExtra {
|
|||
pub to_replace: bool,
|
||||
}
|
||||
|
||||
/// The tabs of the Find and Replace dialog (#712).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FindTab {
|
||||
#[default]
|
||||
Find,
|
||||
Replace,
|
||||
GoTo,
|
||||
}
|
||||
|
||||
/// What Go To moves to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GoToWhat {
|
||||
#[default]
|
||||
Page,
|
||||
Bookmark,
|
||||
}
|
||||
|
||||
/// The Special ▾ menu: (name, code) for Find what, without and with wildcards.
|
||||
const SPECIAL_FIND: &[(&str, &str)] = &[
|
||||
("Paragraph Mark", "^p"),
|
||||
|
|
@ -202,14 +221,15 @@ pub enum Dialog {
|
|||
regex: bool,
|
||||
/// Search options under "Search Options" (#543).
|
||||
extra: FindExtra,
|
||||
replace_mode: bool,
|
||||
/// Find, Replace or Go To, one dialog as in Word (#712).
|
||||
tab: FindTab,
|
||||
goto_what: GoToWhat,
|
||||
/// Go To's box: a page number (`+2`, `-1` move from the current page) or a bookmark.
|
||||
goto_target: String,
|
||||
message: String,
|
||||
/// Put the caret in Find what on the first frame, so typing searches (#772).
|
||||
focus: bool,
|
||||
},
|
||||
Goto {
|
||||
page: String,
|
||||
},
|
||||
InsertTable {
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
|
|
@ -301,6 +321,10 @@ pub enum Dialog {
|
|||
},
|
||||
Commands {
|
||||
query: String,
|
||||
/// Opened by a shortcut that may also send its key's text (Alt+Q's "q", in the same frame
|
||||
/// or the next): that text isn't typed into the box; the next key pressed ends this (#819).
|
||||
#[serde(skip)]
|
||||
swallow: bool,
|
||||
},
|
||||
/// Columns, Symbol and Field ([`crate::dialogs_insert`], #321).
|
||||
Insert(Box<crate::dialogs_insert::InsertDialog>),
|
||||
|
|
@ -848,9 +872,9 @@ impl Dialog {
|
|||
Dialog::Paragraph { .. } => "paragraph",
|
||||
Dialog::Tabs { .. } => "tabs",
|
||||
Dialog::Borders { .. } => "borders",
|
||||
Dialog::Find { replace_mode: false, .. } => "find",
|
||||
Dialog::Find { .. } => "replace",
|
||||
Dialog::Goto { .. } => "goto",
|
||||
Dialog::Find { tab: FindTab::Find, .. } => "find",
|
||||
Dialog::Find { tab: FindTab::Replace, .. } => "replace",
|
||||
Dialog::Find { tab: FindTab::GoTo, .. } => "goto",
|
||||
Dialog::InsertTable { .. } => "insertTable",
|
||||
Dialog::PageSetup { .. } => "pageSetup",
|
||||
Dialog::Link { .. } => "link",
|
||||
|
|
@ -1006,7 +1030,7 @@ impl Dialog {
|
|||
};
|
||||
Dialog::Borders { form: Box::new(crate::dialogs_para::BordersForm::read(app, tab)?) }
|
||||
}
|
||||
"find" | "replace" => Dialog::Find {
|
||||
"find" | "replace" | "goto" => Dialog::Find {
|
||||
query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() },
|
||||
replace: app.session.find.replace.clone(),
|
||||
match_case: app.session.find.match_case,
|
||||
|
|
@ -1025,11 +1049,16 @@ impl Dialog {
|
|||
|| app.session.find.ignore_space,
|
||||
to_replace: false,
|
||||
},
|
||||
replace_mode: name == "replace",
|
||||
tab: match name {
|
||||
"replace" => FindTab::Replace,
|
||||
"goto" => FindTab::GoTo,
|
||||
_ => FindTab::Find,
|
||||
},
|
||||
goto_what: GoToWhat::Page,
|
||||
goto_target: String::new(),
|
||||
message: String::new(),
|
||||
focus: true,
|
||||
},
|
||||
"goto" => Dialog::Goto { page: String::new() },
|
||||
"insertTable" => Dialog::InsertTable { rows: 2, cols: 5 },
|
||||
"pageSetup" => {
|
||||
let sp = wordcraft_engine::cmd::page::sect(&app.session);
|
||||
|
|
@ -1087,7 +1116,7 @@ impl Dialog {
|
|||
let regions = table_regions(app, &id);
|
||||
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
|
||||
}
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"commands" => Dialog::Commands { query: String::new(), swallow: true },
|
||||
"icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() },
|
||||
"tableProperties" => {
|
||||
let form = Box::new(TableForm::read(app)?);
|
||||
|
|
@ -1294,9 +1323,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
|||
Dialog::Paragraph { .. } => "Paragraph",
|
||||
Dialog::Tabs { .. } => "Tabs",
|
||||
Dialog::Borders { .. } => "Borders and Shading",
|
||||
Dialog::Find { replace_mode: false, .. } => "Find",
|
||||
Dialog::Find { .. } => "Find and Replace",
|
||||
Dialog::Goto { .. } => "Go To",
|
||||
Dialog::InsertTable { .. } => "Insert Table",
|
||||
Dialog::PageSetup { .. } => "Page Setup",
|
||||
Dialog::Link { .. } => "Insert Hyperlink",
|
||||
|
|
@ -1666,15 +1693,20 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form),
|
||||
Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form),
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, extra, replace_mode, message, focus } => {
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, extra, tab, goto_what, goto_target, message, focus } => {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() {
|
||||
*replace_mode = false;
|
||||
}
|
||||
if ui.selectable_label(*replace_mode, tl!("Replace")).clicked() {
|
||||
*replace_mode = true;
|
||||
for (t, label) in [(FindTab::Find, "Find"), (FindTab::Replace, "Replace"), (FindTab::GoTo, "Go To")] {
|
||||
if ui.selectable_label(*tab == t, tl!(label)).clicked() && *tab != t {
|
||||
*tab = t;
|
||||
message.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if *tab == FindTab::GoTo {
|
||||
return goto_body(app, ui, goto_what, goto_target, message);
|
||||
}
|
||||
let replace_mode = *tab == FindTab::Replace;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Find what:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new("find_what")).desired_width(240.0));
|
||||
|
|
@ -1685,7 +1717,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
extra.to_replace = false;
|
||||
}
|
||||
});
|
||||
if *replace_mode {
|
||||
if replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Replace with:"));
|
||||
if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() {
|
||||
|
|
@ -1734,7 +1766,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
"ignorePunct": extra.ignore_punct,
|
||||
"ignoreSpace": extra.ignore_space,
|
||||
});
|
||||
if !*replace_mode {
|
||||
if !replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
let lit = app.session.find.highlight;
|
||||
if ui.selectable_label(lit, tl!("Reading Highlight")).clicked() {
|
||||
|
|
@ -1753,7 +1785,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if *replace_mode {
|
||||
if replace_mode {
|
||||
if ui.button(tl!("Replace All")).clicked() {
|
||||
match app.run("edit.replaceAll", opts.clone()) {
|
||||
Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]),
|
||||
|
|
@ -1777,17 +1809,6 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
});
|
||||
close
|
||||
}
|
||||
Dialog::Goto { page } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Enter page number:"));
|
||||
ui.text_edit_singleline(page);
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, tl!("Go To"));
|
||||
if ok && let Ok(n) = page.trim().parse::<u64>() {
|
||||
let _ = app.run("edit.goto", json!({"page": n}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Enclose { style, text, shape } => {
|
||||
ui.label(egui::RichText::new(tl!("Style")).font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -2212,35 +2233,44 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
}
|
||||
Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f),
|
||||
Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f),
|
||||
Dialog::Commands { query } => {
|
||||
Dialog::Commands { query, swallow } => {
|
||||
if *swallow {
|
||||
ui.ctx().input_mut(|i| i.events.retain(|e| !swallow_shortcut_text(swallow, e)));
|
||||
}
|
||||
let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0));
|
||||
r.request_focus();
|
||||
let q = query.to_lowercase();
|
||||
let localized_q = crate::i18n::lowercase(query);
|
||||
let reg = app.session.registry.clone();
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
!q.is_empty()
|
||||
&& (c.label.to_lowercase().contains(&q)
|
||||
|| crate::i18n::lowercase(tl!(c.label)).contains(&localized_q)
|
||||
|| c.id.to_lowercase().contains(&q)
|
||||
|| c.location.to_lowercase().contains(&q)
|
||||
|| crate::i18n::lowercase(&crate::i18n::location(c.location)).contains(&localized_q))
|
||||
})
|
||||
.collect();
|
||||
// An empty box suggests a few common commands, as Word's Search does.
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = if query.trim().is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Suggested")).font(semibold(12.5)));
|
||||
SEARCH_SUGGESTIONS.iter().filter_map(|id| reg.get(id)).collect()
|
||||
} else {
|
||||
reg.all().iter().filter(|c| command_matches(c, query)).collect()
|
||||
};
|
||||
hits.truncate(14);
|
||||
let mut close = false;
|
||||
for c in hits {
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mut run: Option<&str> = enter.then(|| hits.first().map(|c| c.id)).flatten();
|
||||
for c in &hits {
|
||||
let sc = crate::widgets::shortcut_text(app, c.id);
|
||||
let label = format!("{} — {}", tl!(c.label), crate::i18n::location(c.location));
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
|
||||
let _ = app.run(c.id, json!({}));
|
||||
close = true;
|
||||
run = Some(c.id);
|
||||
}
|
||||
}
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
// Search also finds the words in the document (the Navigation pane's results).
|
||||
let text = query.trim().to_string();
|
||||
let mut find = false;
|
||||
if !text.is_empty() {
|
||||
ui.separator();
|
||||
let label = crate::i18n::fmt(tl!("Find “{text}” in the document"), &[("text", &text)]);
|
||||
find = ui.add(egui::Button::new(label).min_size(vec2(380.0, 0.0))).clicked() || (enter && hits.is_empty());
|
||||
}
|
||||
if let Some(id) = run {
|
||||
let _ = app.run(id, json!({}));
|
||||
} else if find {
|
||||
find_in_navigation_pane(app, ui.ctx(), &text);
|
||||
}
|
||||
run.is_some() || find || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected),
|
||||
Dialog::PasteSpecial { formats, choice, payload, note } => {
|
||||
|
|
@ -2329,6 +2359,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
|||
.inner
|
||||
});
|
||||
let buttons = buttons.inner;
|
||||
// The keyboard alone answers, as in Word (#861): Save has focus when the prompt opens,
|
||||
// so Enter saves, and the arrow keys (egui's focus navigation) or Tab move between the
|
||||
// buttons, shown as Save, Don't Save, Cancel.
|
||||
if ui.memory(|m| m.focused().is_none())
|
||||
&& let Some((save, _)) = buttons.get(2)
|
||||
{
|
||||
save.request_focus();
|
||||
}
|
||||
// Enter clicks the focused button; it means Save only when none of them has focus.
|
||||
let focused = buttons.iter().any(|(b, _)| b.has_focus());
|
||||
let answer = if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
|
|
@ -2991,6 +3029,145 @@ fn describe(st: &Value) -> String {
|
|||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// What Search lists before anything is typed: common commands (#819).
|
||||
const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "file.print"];
|
||||
|
||||
/// Search's "Find … in the document": the Navigation pane opens on its results for `text`.
|
||||
fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string()));
|
||||
app.ui.nav_tab = "results".into();
|
||||
let _ = app.run("view.navigationPane", json!({"value": true}));
|
||||
let _ = app.run("edit.find", json!({"text": text}));
|
||||
}
|
||||
|
||||
/// Whether `e` is the text of the shortcut that opened a box (see `Dialog::Commands::swallow`):
|
||||
/// a plain key press first means the user is typing, and one swallowed text is all there is.
|
||||
fn swallow_shortcut_text(swallow: &mut bool, e: &egui::Event) -> bool {
|
||||
match e {
|
||||
egui::Event::Key { pressed: true, modifiers, .. } if !modifiers.alt => {
|
||||
*swallow = false;
|
||||
false
|
||||
}
|
||||
egui::Event::Text(_) if *swallow => {
|
||||
*swallow = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Search's filter: every word of the query is in the command's label, location or id, in English
|
||||
/// or in the interface language, so "insert table" finds Insert › Tables › Table (#819).
|
||||
fn command_matches(c: &wordcraft_engine::CommandSpec, query: &str) -> bool {
|
||||
if query.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let hay = format!(
|
||||
"{} {} {} {} {}",
|
||||
c.label.to_lowercase(),
|
||||
c.location.to_lowercase(),
|
||||
c.id.to_lowercase(),
|
||||
crate::i18n::lowercase(tl!(c.label)),
|
||||
crate::i18n::lowercase(&crate::i18n::location(c.location))
|
||||
);
|
||||
query.split_whitespace().all(|w| hay.contains(&w.to_lowercase()) || hay.contains(&crate::i18n::lowercase(w)))
|
||||
}
|
||||
|
||||
/// The Find and Replace dialog's Go To tab (#712): a page (a number, or `+2` / `-1` from the
|
||||
/// current page; empty, Next and Previous step a page) or a bookmark. Returns true to close.
|
||||
fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut String, message: &mut String) -> bool {
|
||||
// The document's bookmarks, only while they are listed (finding them walks the document).
|
||||
let bookmarks: Vec<String> = if *what == GoToWhat::Bookmark {
|
||||
app.session.doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| !n.starts_with('_')).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Go to what:"));
|
||||
for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] {
|
||||
if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w {
|
||||
*what = w;
|
||||
target.clear();
|
||||
message.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.vertical(|ui| match what {
|
||||
GoToWhat::Page => {
|
||||
ui.label(tl!("Enter page number:"));
|
||||
ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).request_focus();
|
||||
ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak());
|
||||
}
|
||||
GoToWhat::Bookmark => {
|
||||
ui.label(tl!("Enter bookmark name:"));
|
||||
egui::ComboBox::from_id_salt("goto_bookmark").width(220.0).selected_text(target.as_str()).show_ui(ui, |ui| {
|
||||
for b in &bookmarks {
|
||||
ui.selectable_value(target, b.clone(), b.as_str());
|
||||
}
|
||||
});
|
||||
if bookmarks.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).weak());
|
||||
}
|
||||
let mut close = false;
|
||||
let mut go: Option<Value> = None;
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let page_step = *what == GoToWhat::Page && target.trim().is_empty();
|
||||
if page_step && ui.button(tl!("Previous")).clicked() {
|
||||
go = Some(json!({"pageBy": -1}));
|
||||
}
|
||||
let label = if page_step { tl!("Next") } else { tl!("Go To") };
|
||||
if ui.button(label).clicked() || enter {
|
||||
go = match what {
|
||||
GoToWhat::Page => match goto_page(target) {
|
||||
Some(p) => Some(p),
|
||||
None => {
|
||||
*message = tl!("Type a page number, or +2 / -1.").to_string();
|
||||
None
|
||||
}
|
||||
},
|
||||
GoToWhat::Bookmark if !target.is_empty() => Some(json!({"bookmark": target.clone()})),
|
||||
GoToWhat::Bookmark => None,
|
||||
};
|
||||
}
|
||||
if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if let Some(params) = go {
|
||||
match app.run("edit.goto", params) {
|
||||
Ok(_) => message.clear(),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
close
|
||||
}
|
||||
|
||||
/// Go To's page box as `edit.goto` params: empty steps one page on, `+n` / `-n` move from the
|
||||
/// current page, a number is that page. `None` for anything else.
|
||||
fn goto_page(text: &str) -> Option<Value> {
|
||||
let t = text.trim().replace('\u{2212}', "-");
|
||||
if t.is_empty() {
|
||||
return Some(json!({"pageBy": 1}));
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix('+') {
|
||||
return rest.parse::<i64>().ok().filter(|n| *n >= 0).map(|n| json!({"pageBy": n}));
|
||||
}
|
||||
if t.starts_with('-') {
|
||||
return t.parse::<i64>().ok().map(|n| json!({"pageBy": n}));
|
||||
}
|
||||
t.parse::<u64>().ok().filter(|n| *n >= 1).map(|n| json!({"page": n}))
|
||||
}
|
||||
|
||||
/// path data; Insert (or a double click) runs `insert.icon`. Returns true to close.
|
||||
fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool {
|
||||
use wordcraft_render::icon_lib::{self, Category};
|
||||
|
|
@ -3103,6 +3280,31 @@ mod tests {
|
|||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
/// Go To's page box (#712): a number, +n / -n from the current page (a typographic minus
|
||||
/// too), empty for the next page; anything else is refused.
|
||||
#[test]
|
||||
fn go_to_reads_its_page_box() {
|
||||
assert_eq!(goto_page("7"), Some(json!({"page": 7})));
|
||||
assert_eq!(goto_page(" +2 "), Some(json!({"pageBy": 2})));
|
||||
assert_eq!(goto_page("-1"), Some(json!({"pageBy": -1})));
|
||||
assert_eq!(goto_page("\u{2212}3"), Some(json!({"pageBy": -3})));
|
||||
assert_eq!(goto_page(""), Some(json!({"pageBy": 1})));
|
||||
for junk in ["0", "abc", "+x", "1.5", "99999999999999999999999"] {
|
||||
assert_eq!(goto_page(junk), None, "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Find, Replace and Go To are one dialog with three tabs (#712).
|
||||
#[test]
|
||||
fn find_replace_and_go_to_are_one_dialog() {
|
||||
let mut app = app_with("One");
|
||||
for (name, tab) in [("find", FindTab::Find), ("replace", FindTab::Replace), ("goto", FindTab::GoTo)] {
|
||||
let _ = app.run("ui.dialog", json!({"name": name}));
|
||||
assert!(matches!(&app.dialog, Some(Dialog::Find { tab: t, .. }) if *t == tab), "{name}");
|
||||
assert_eq!(app.dialog.as_ref().map(|d| d.name()), Some(name));
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with(text: &str) -> WordApp {
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let _ = app.run("document.setText", json!({"text": text}));
|
||||
|
|
@ -3113,6 +3315,40 @@ mod tests {
|
|||
app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// #819: the text a shortcut sends with its key isn't typed into the box it opened, whether it
|
||||
/// comes in the same frame or the next; real typing (a key press first) is.
|
||||
#[test]
|
||||
fn shortcut_text_is_swallowed_once() {
|
||||
let key = |key, alt| egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { alt, ..Default::default() },
|
||||
};
|
||||
let text = |t: &str| egui::Event::Text(t.into());
|
||||
let run = |events: &[egui::Event]| {
|
||||
let mut swallow = true;
|
||||
events.iter().filter(|e| !swallow_shortcut_text(&mut swallow, e)).cloned().collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(run(&[key(egui::Key::Q, true), text("q")]), vec![key(egui::Key::Q, true)], "Alt+Q's q, same frame");
|
||||
assert_eq!(run(&[text("q"), text("b")]), vec![text("b")], "the q a frame late; then typing");
|
||||
assert_eq!(run(&[key(egui::Key::B, false), text("b")]), vec![key(egui::Key::B, false), text("b")], "a click-opened box takes typing");
|
||||
}
|
||||
|
||||
/// #819: Search matches every word anywhere in the label, location or id, in any order.
|
||||
#[test]
|
||||
fn command_search_matches_every_word() {
|
||||
let app = app_with("");
|
||||
let reg = app.session.registry.clone();
|
||||
let table = reg.get("insert.table").unwrap();
|
||||
assert!(command_matches(table, "insert table"), "the box's own example");
|
||||
assert!(command_matches(table, " Table INSERT "), "any order, any case, extra spaces");
|
||||
assert!(!command_matches(table, "insert chart"), "every word has to match");
|
||||
assert!(!command_matches(table, ""), "an empty box lists nothing");
|
||||
assert!(!command_matches(table, " "));
|
||||
}
|
||||
|
||||
/// Ordinary Turkish queries must find dotted-I labels, while canonical English labels and
|
||||
/// ASCII command IDs retain their old case-insensitive search in every interface language.
|
||||
#[test]
|
||||
|
|
@ -3147,7 +3383,7 @@ mod tests {
|
|||
("en", "INSERT.TABLE", "Table — Insert › Tables"),
|
||||
] {
|
||||
h.state_mut().ui.language = language.into();
|
||||
h.state_mut().dialog = Some(Dialog::Commands { query: query.into() });
|
||||
h.state_mut().dialog = Some(Dialog::Commands { query: query.into(), swallow: false });
|
||||
h.run_steps(4);
|
||||
assert!(
|
||||
h.query(egui_kittest::kittest::By::new().role(egui::accesskit::Role::Button).label_contains(expected)).is_some(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2441,3 +2441,26 @@
|
|||
Pastes the clipboard's picture. Поставя картината от клипборда.
|
||||
Region: Регион:
|
||||
Automatic ({region}) Автоматично ({region})
|
||||
Undo 1 Action Отмяна на 1 действие
|
||||
Undo Actions Отмяна на действия
|
||||
Undo History История на отмяната
|
||||
Undo {n} Actions Отмяна на {n} действия
|
||||
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 се отваря на Файл › Начало с празен документ, шаблоните и последните документи. Изкл.: отваря се празен документ.
|
||||
Typing Въвеждане
|
||||
Level 4 Ниво 4
|
||||
Level 5 Ниво 5
|
||||
Level 6 Ниво 6
|
||||
Level 7 Ниво 7
|
||||
Level 8 Ниво 8
|
||||
Level 9 Ниво 9
|
||||
Select Column Избор на колона
|
||||
Select Table Cells by Clicking Избор на клетки от таблица с щракване
|
||||
Insert Row or Column by Clicking Вмъкване на ред или колона с щракване
|
||||
Move Table by Dragging Преместване на таблица с плъзгане
|
||||
AutoFit Column by Double-Clicking Автопобиране на колона с двойно щракване
|
||||
Insert Caption… Вмъкване на надпис…
|
||||
Table Properties… Свойства на таблица…
|
||||
Search Търсене
|
||||
Title Bar Заглавна лента
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -535,6 +535,7 @@
|
|||
Insert Above Vložit nad
|
||||
Insert Below Vložit pod
|
||||
Insert Caption Vložit titulek
|
||||
Insert Caption… Vložit titulek…
|
||||
Insert Citation Vložit citaci
|
||||
Insert Endnote Vložit vysvětlivku
|
||||
Insert Footnote Vložit poznámku pod čarou
|
||||
|
|
@ -1111,6 +1112,7 @@
|
|||
Table of Contents Obsah
|
||||
Table of\nContents Obsah
|
||||
Table Properties Vlastnosti tabulky
|
||||
Table Properties… Vlastnosti tabulky…
|
||||
Table Row Height Výška řádku tabulky
|
||||
Table style Styl tabulky
|
||||
Table Style Options Možnosti stylu tabulky
|
||||
|
|
@ -1876,6 +1878,11 @@
|
|||
Word XML Document (*.xml) Dokument Word XML (*.xml)
|
||||
Deleted Odstraněno
|
||||
Resize Table by Dragging Změnit velikost tabulky přetažením
|
||||
Select Column Vybrat sloupec
|
||||
Select Table Cells by Clicking Vybrat buňky tabulky kliknutím
|
||||
Insert Row or Column by Clicking Vložit řádek nebo sloupec kliknutím
|
||||
Move Table by Dragging Přesunout tabulku přetažením
|
||||
AutoFit Column by Double-Clicking Přizpůsobit sloupec dvojitým kliknutím
|
||||
Print Markup Tisknout revize
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Komentáře vedle každé stránky a sledované změny v souborech PDF, výtiscích a obrázcích; seznam komentářů na konci webových stránek, Markdownu a textu.
|
||||
Add Font… Přidat písmo…
|
||||
|
|
@ -2043,6 +2050,13 @@
|
|||
Your system has animation effects turned off. Efekty animace jsou v systému vypnuté.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Systém neuvádí žádné nastavení animací, proto efekty animace zůstávají zapnuté.
|
||||
Do Not Check Spelling or Grammar Nekontrolovat pravopis ani gramatiku
|
||||
Undo Actions Vrátit více akcí
|
||||
Undo History Historie vrácení
|
||||
Undo 1 Action Vrátit 1 akci
|
||||
Undo {n} Actions Vrátit akce: {n}
|
||||
Start-up Spuštění
|
||||
Show the Start screen when WordCraft starts Při spuštění WordCraftu zobrazit úvodní obrazovku
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Zapnuto: WordCraft se otevře na kartě Soubor › Domů s prázdným dokumentem, šablonami a nedávnými dokumenty. Vypnuto: otevře prázdný dokument.
|
||||
Couldn't make WordCraft the default: {error} WordCraft se nepodařilo nastavit jako výchozí: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. Ve Výchozích aplikacích vyberte WordCraft pro typy dokumentů, které má otevírat.
|
||||
Make WordCraft the default for Word documents… Nastavit WordCraft jako výchozí pro dokumenty Word…
|
||||
|
|
@ -2064,3 +2078,5 @@
|
|||
Correct accidental usage of cAPS LOCK key Opravovat náhodné použití klávesy cAPS LOCK
|
||||
Region: Oblast:
|
||||
Automatic ({region}) Automaticky ({region})
|
||||
Search Hledat
|
||||
Title Bar Záhlaví okna
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -416,6 +416,7 @@
|
|||
Insert Above Oberhalb einfügen
|
||||
Insert Below Unterhalb einfügen
|
||||
Insert Caption Beschriftung einfügen
|
||||
Insert Caption… Beschriftung einfügen…
|
||||
Insert Citation Zitat einfügen
|
||||
Insert Endnote Endnote einfügen
|
||||
Insert Footnote Fußnote einfügen
|
||||
|
|
@ -791,6 +792,7 @@
|
|||
Table of Contents Inhaltsverzeichnis
|
||||
Table of\nContents Inhalts-\nverzeichnis
|
||||
Table Properties Tabelleneigenschaften
|
||||
Table Properties… Tabelleneigenschaften…
|
||||
Table Row Height Tabellenzeilenhöhe
|
||||
Table Style Options Tabellenformat-Optionen
|
||||
Table Styles Tabellenformate
|
||||
|
|
@ -840,6 +842,10 @@
|
|||
Underline Words Only Nur Wörter unterstreichen
|
||||
Undo Rückgängig
|
||||
Undo {action} {action} rückgängig
|
||||
Undo Actions Mehrere Aktionen rückgängig
|
||||
Undo History Rückgängig-Verlauf
|
||||
Undo 1 Action 1 Aktion rückgängig
|
||||
Undo {n} Actions {n} Aktionen rückgängig
|
||||
Ungroup Gruppierung aufheben
|
||||
Up Nach oben
|
||||
Update Field Feld aktualisieren
|
||||
|
|
@ -1457,6 +1463,11 @@
|
|||
First row of data contains column headers Erste Datenzeile enthält Spaltenüberschriften
|
||||
Deleted Gelöscht
|
||||
Resize Table by Dragging Tabellengröße durch Ziehen ändern
|
||||
Select Column Spalte auswählen
|
||||
Select Table Cells by Clicking Tabellenzellen per Klick auswählen
|
||||
Insert Row or Column by Clicking Zeile oder Spalte per Klick einfügen
|
||||
Move Table by Dragging Tabelle durch Ziehen verschieben
|
||||
AutoFit Column by Double-Clicking Spalte per Doppelklick automatisch anpassen
|
||||
Print Markup Markups drucken
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Kommentare neben jeder Seite und nachverfolgte Änderungen in PDFs, Ausdrucken und Bildern; eine Kommentarliste am Ende von Webseiten, Markdown und Text.
|
||||
1. Formatting restrictions 1. Formatierungseinschränkungen
|
||||
|
|
@ -2037,6 +2048,9 @@
|
|||
Your system has animation effects turned off. Auf Ihrem System sind Animationseffekte deaktiviert.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Ihr System meldet keine Animationseinstellung, daher bleiben Animationseffekte aktiviert.
|
||||
Do Not Check Spelling or Grammar Rechtschreibung und Grammatik nicht prüfen
|
||||
Start-up Programmstart
|
||||
Show the Start screen when WordCraft starts Startbildschirm beim Start von WordCraft anzeigen
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ein: WordCraft öffnet sich auf Datei › Start mit einem leeren Dokument, den Vorlagen und Ihren zuletzt verwendeten Dokumenten. Aus: Es öffnet ein leeres Dokument.
|
||||
Couldn't make WordCraft the default: {error} WordCraft konnte nicht als Standard festgelegt werden: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. Wählen Sie unter „Standard-Apps“ WordCraft für die Dokumenttypen, die es öffnen soll.
|
||||
Make WordCraft the default for Word documents… WordCraft als Standard für Word-Dokumente festlegen…
|
||||
|
|
@ -2058,3 +2072,5 @@
|
|||
Correct accidental usage of cAPS LOCK key Unbeabsichtigtes Verwenden der fESTSTELLTASTE korrigieren
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatisch ({region})
|
||||
Search Suchen
|
||||
Title Bar Titelleiste
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -167,6 +167,7 @@
|
|||
Border Painter Pincel de bordes
|
||||
Border\nPainter Pincel de\nbordes
|
||||
Borders Bordes
|
||||
Search Buscar
|
||||
Show the mini toolbar when text is selected Mostrar la minibarra de herramientas al seleccionar texto
|
||||
Spaces Espacios
|
||||
Spelling Ortografía
|
||||
|
|
@ -183,6 +184,7 @@
|
|||
Page Border Borde de página
|
||||
Apply to: Aplicar a:
|
||||
These marks show even when Show/Hide ¶ is off. Estas marcas se muestran aunque Mostrar u ocultar ¶ esté desactivado.
|
||||
Title Bar Barra de título
|
||||
Typing replaces selected text Lo que escribes reemplaza el texto seleccionado
|
||||
Update fields before printing Actualizar campos antes de imprimir
|
||||
Use the Insert key to switch overtype on and off Usar la tecla Insert para activar y desactivar la sobrescritura
|
||||
|
|
@ -475,6 +477,7 @@
|
|||
Insert Above Insertar encima
|
||||
Insert Below Insertar debajo
|
||||
Insert Caption Insertar leyenda
|
||||
Insert Caption… Insertar leyenda…
|
||||
Insert Citation Insertar cita
|
||||
Insert Endnote Insertar nota final
|
||||
Insert Footnote Insertar nota al pie
|
||||
|
|
@ -898,6 +901,7 @@
|
|||
Table Design Diseño de tabla
|
||||
Table Layout Disposición de tabla
|
||||
Table Properties Propiedades de tabla
|
||||
Table Properties… Propiedades de tabla…
|
||||
Table Row Height Alto de fila de tabla
|
||||
Table Style Options Opciones de estilo de tabla
|
||||
Table Styles Estilos de tabla
|
||||
|
|
@ -955,6 +959,10 @@
|
|||
Underline Words Only Subrayar solo las palabras
|
||||
Undo Deshacer
|
||||
Undo {action} Deshacer {action}
|
||||
Undo Actions Deshacer acciones
|
||||
Undo History Historial de deshacer
|
||||
Undo 1 Action Deshacer 1 acción
|
||||
Undo {n} Actions Deshacer {n} acciones
|
||||
Unformatted Text Texto sin formato
|
||||
Up Arriba
|
||||
Update Field Actualizar campo
|
||||
|
|
@ -2097,5 +2105,339 @@
|
|||
Your system has animation effects turned on. Tu sistema tiene activados los efectos de animación.
|
||||
Your system has animation effects turned off. Tu sistema tiene desactivados los efectos de animación.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Tu sistema no indica ningún ajuste de animación, así que los efectos de animación siguen activados.
|
||||
Select Column Seleccionar columna
|
||||
Select Table Cells by Clicking Seleccionar celdas de tabla haciendo clic
|
||||
Insert Row or Column by Clicking Insertar fila o columna haciendo clic
|
||||
Move Table by Dragging Mover tabla arrastrando
|
||||
AutoFit Column by Double-Clicking Ajustar automáticamente la columna con doble clic
|
||||
|
||||
# Equation gallery (structures, symbol sets, built-in equations), built-in style names, watermark
|
||||
# menu labels and other strings that only the Italian and Russian catalogs had.
|
||||
Good morning Buenos días
|
||||
Good afternoon Buenas tardes
|
||||
Good evening Buenas noches
|
||||
Typing Escritura
|
||||
Bayes' rule Regla de Bayes
|
||||
Binomial expansion of (x+a)ⁿ Desarrollo binomial de (x+a)ⁿ
|
||||
Circle area from its radius Área del círculo a partir de su radio
|
||||
Convert between built-up and linear form Convertir entre el formato profesional y el lineal
|
||||
Derivative as a limit Derivada como límite
|
||||
Euler's identity Identidad de Euler
|
||||
Exponential series Serie exponencial
|
||||
Integral of e^(−x²) Integral de e^(−x²)
|
||||
Justification of a display equation Justificación de una ecuación en su propia línea
|
||||
Normal probability density Densidad de probabilidad normal
|
||||
Number the equation; the number sits at the right margin (or type # then the number) Numerar la ecuación; el número va al margen derecho (o escribe # y luego el número)
|
||||
Power series of (1+x)ⁿ Serie de potencias de (1+x)ⁿ
|
||||
Roots of ax²+bx+c Raíces de ax²+bx+c
|
||||
Show the equation in the line of text Mostrar la ecuación en la línea de texto
|
||||
Show the equation on a line of its own Mostrar la ecuación en una línea propia
|
||||
Sides of a right triangle Lados de un triángulo rectángulo
|
||||
Sine and cosine series of f(x) Serie de senos y cosenos de f(x)
|
||||
Sum of cosines Suma de cosenos
|
||||
Sum or difference of sines Suma o diferencia de senos
|
||||
Type equations in LaTeX Escribir ecuaciones en LaTeX
|
||||
Type equations in the linear format (UnicodeMath) Escribir ecuaciones en el formato lineal (UnicodeMath)
|
||||
Type normal (non-math) text in the equation Escribir texto normal (no matemático) en la ecuación
|
||||
Level 1 Nivel 1
|
||||
Level 2 Nivel 2
|
||||
Level 3 Nivel 3
|
||||
Level 4 Nivel 4
|
||||
Level 5 Nivel 5
|
||||
Level 6 Nivel 6
|
||||
Level 7 Nivel 7
|
||||
Level 8 Nivel 8
|
||||
Level 9 Nivel 9
|
||||
1×2 Empty Matrix Matriz vacía 1×2
|
||||
1×3 Empty Matrix Matriz vacía 1×3
|
||||
2×1 Empty Matrix Matriz vacía 2×1
|
||||
2×2 Determinant Determinante 2×2
|
||||
2×2 Empty Matrix Matriz vacía 2×2
|
||||
2×2 Identity Matrix Matriz identidad 2×2
|
||||
2×2 Identity Matrix (sparse) Matriz identidad 2×2 (dispersa)
|
||||
2×2 Matrix in Brackets Matriz 2×2 entre corchetes
|
||||
2×2 Matrix in Double Bars Matriz 2×2 entre barras dobles
|
||||
2×2 Matrix in Parentheses Matriz 2×2 entre paréntesis
|
||||
2×3 Empty Matrix Matriz vacía 2×3
|
||||
3×1 Empty Matrix Matriz vacía 3×1
|
||||
3×2 Empty Matrix Matriz vacía 3×2
|
||||
3×3 Empty Matrix Matriz vacía 3×3
|
||||
3×3 Identity Matrix Matriz identidad 3×3
|
||||
3×3 Identity Matrix (sparse) Matriz identidad 3×3 (dispersa)
|
||||
A xor B A xor B
|
||||
ASAP LO ANTES POSIBLE
|
||||
AaBbCcDd AaBbCcDd
|
||||
Absolute value cases Casos del valor absoluto
|
||||
Accents Acentos
|
||||
Acute Acento agudo
|
||||
All - Linear Todo - Lineal
|
||||
All - Professional Todo - Profesional
|
||||
Angle Brackets Corchetes angulares
|
||||
Angle Brackets with Separator Corchetes angulares con separador
|
||||
Angle Brackets with Two Separators Corchetes angulares con dos separadores
|
||||
Annotation Reference Referencia de comentario
|
||||
Annotation Subject Asunto del comentario
|
||||
Annotation Text Texto del comentario
|
||||
Baseline Dots Puntos en la línea base
|
||||
Basic Math Matemáticas básicas
|
||||
Binomial Coefficient Coeficiente binomial
|
||||
Binomial Coefficient (angle) Coeficiente binomial (angular)
|
||||
Binomial Coefficient with Angle Brackets Coeficiente binomial con corchetes angulares
|
||||
Body text in a short paragraph to show spacing. Texto de cuerpo en un párrafo corto para mostrar el espaciado.
|
||||
Boxed Formula Fórmula enmarcada
|
||||
Boxed Formula (example) Fórmula enmarcada (ejemplo)
|
||||
Boxed Formulas Fórmulas enmarcadas
|
||||
Braces Llaves
|
||||
Braces with Separator Llaves con separador
|
||||
Brackets Corchetes
|
||||
Brackets with Separators Corchetes con separadores
|
||||
Breve Breve
|
||||
CONFIDENTIAL CONFIDENCIAL
|
||||
Cases (Three Conditions) Casos (tres condiciones)
|
||||
Cases (Two Conditions) Casos (dos condiciones)
|
||||
Cases and Stacks Casos y pilas
|
||||
Ceiling Techo
|
||||
Centered as Group Centrado como grupo
|
||||
Change in y over change in x Variación de y sobre variación de x
|
||||
Check Carón
|
||||
Circled Dot Punto en círculo
|
||||
Circled Plus Más en círculo
|
||||
Circled Times Multiplicación en círculo
|
||||
Colon Equal Dos puntos igual
|
||||
Common Accent Objects Objetos de acento comunes
|
||||
Common Brackets Corchetes comunes
|
||||
Common Fractions Fracciones comunes
|
||||
Common Functions Funciones comunes
|
||||
Common Large Operators Operadores grandes comunes
|
||||
Common Operator Structures Estructuras de operadores comunes
|
||||
Common Operators Operadores comunes
|
||||
Common Radicals Radicales comunes
|
||||
Common Subscripts and Superscripts Subíndices y superíndices comunes
|
||||
Contour Integral Integral de contorno
|
||||
Contour Integral with Limits Integral de contorno con límites
|
||||
Contour Integral with Stacked Limits Integral de contorno con límites apilados
|
||||
Contour Integrals Integrales de contorno
|
||||
Coproduct Coproducto
|
||||
Coproduct with Limits Coproducto con límites
|
||||
Coproduct with Lower Limit Coproducto con límite inferior
|
||||
Coproduct with Subscript-Superscript Limits Coproducto con límites en subíndice y superíndice
|
||||
Cosecant Function Función cosecante
|
||||
Cosine 2x Coseno de 2x
|
||||
Cosine Function Función coseno
|
||||
Cotangent Function Función cotangente
|
||||
Cube Root Raíz cúbica
|
||||
Current - Linear Actual - Lineal
|
||||
DO NOT COPY NO COPIAR
|
||||
DRAFT BORRADOR
|
||||
Default Paragraph Font Fuente de párrafo predeterminada
|
||||
Delta Equal To Delta igual a
|
||||
Delta over arrow Delta sobre la flecha
|
||||
Diagonal Dots Puntos suspensivos diagonales
|
||||
Differential Diferencial
|
||||
Differential theta Diferencial theta
|
||||
Differential x Diferencial x
|
||||
Differential y Diferencial y
|
||||
Differentials Diferenciales
|
||||
Dot Punto
|
||||
Dots Puntos suspensivos
|
||||
Double Bar Barra doble
|
||||
Double Dot Punto doble
|
||||
Double Integral Integral doble
|
||||
Double Integral with Limits Integral doble con límites
|
||||
Double Integral with Stacked Limits Integral doble con límites apilados
|
||||
Double Left Arrow with Text Above Flecha doble a la izquierda con texto arriba
|
||||
Double Left Arrow with Text Below Flecha doble a la izquierda con texto abajo
|
||||
Double Left Right Arrow with Text Above Flecha doble bidireccional con texto arriba
|
||||
Double Left Right Arrow with Text Below Flecha doble bidireccional con texto abajo
|
||||
Double Right Arrow with Text Above Flecha doble a la derecha con texto arriba
|
||||
Double Right Arrow with Text Below Flecha doble a la derecha con texto abajo
|
||||
Double Square Brackets Corchetes dobles
|
||||
Double Vertical Bars Barras verticales dobles
|
||||
Double summation Sumatoria doble
|
||||
Empty Matrices Matrices vacías
|
||||
Envelope Address Dirección del sobre
|
||||
Envelope Return Remitente del sobre
|
||||
Equal Equal Igual igual
|
||||
Equal by Definition Igual por definición
|
||||
Exponential Exponencial
|
||||
Floor Suelo
|
||||
Functions Funciones
|
||||
Geometry Geometría
|
||||
Grave Acento grave
|
||||
Greek Letters Letras griegas
|
||||
Grid Table 1 Light Tabla de cuadrícula 1 clara
|
||||
Grouping Character Above Carácter de agrupación arriba
|
||||
Grouping Character Below Carácter de agrupación abajo
|
||||
Hat Acento circunflejo
|
||||
Hyperbolic Cosecant Function Función cosecante hiperbólica
|
||||
Hyperbolic Cosine Function Función coseno hiperbólico
|
||||
Hyperbolic Cotangent Function Función cotangente hiperbólica
|
||||
Hyperbolic Functions Funciones hiperbólicas
|
||||
Hyperbolic Secant Function Función secante hiperbólica
|
||||
Hyperbolic Sine Function Función seno hiperbólico
|
||||
Hyperbolic Tangent Function Función tangente hiperbólica
|
||||
Hypotenuse Hipotenusa
|
||||
Identity Matrices Matrices identidad
|
||||
Index Heading Título de índice
|
||||
Integral with Limits Integral con límites
|
||||
Integral with Stacked Limits Integral con límites apilados
|
||||
Integrals Integrales
|
||||
Intersection Intersección
|
||||
Intersection with Limits Intersección con límites
|
||||
Intersection with Lower Limit Intersección con límite inferior
|
||||
Intersection with Subscript-Superscript Limits Intersección con límites en subíndice y superíndice
|
||||
Inverse Cosecant Function Función cosecante inversa
|
||||
Inverse Cosine Function Función coseno inverso
|
||||
Inverse Cotangent Function Función cotangente inversa
|
||||
Inverse Functions Funciones inversas
|
||||
Inverse Hyperbolic Cosecant Function Función cosecante hiperbólica inversa
|
||||
Inverse Hyperbolic Cosine Function Función coseno hiperbólico inverso
|
||||
Inverse Hyperbolic Cotangent Function Función cotangente hiperbólica inversa
|
||||
Inverse Hyperbolic Functions Funciones hiperbólicas inversas
|
||||
Inverse Hyperbolic Secant Function Función secante hiperbólica inversa
|
||||
Inverse Hyperbolic Sine Function Función seno hiperbólico inverso
|
||||
Inverse Hyperbolic Tangent Function Función tangente hiperbólica inversa
|
||||
Inverse Secant Function Función secante inversa
|
||||
Inverse Sine Function Función seno inverso
|
||||
Inverse Tangent Function Función tangente inversa
|
||||
Left Arrow with Text Above Flecha a la izquierda con texto arriba
|
||||
Left Arrow with Text Below Flecha a la izquierda con texto abajo
|
||||
Left Bracket Twice Dos corchetes izquierdos
|
||||
Left Right Arrow Above Flecha bidireccional arriba
|
||||
Left Right Arrow with Text Above Flecha bidireccional con texto arriba
|
||||
Left Right Arrow with Text Below Flecha bidireccional con texto abajo
|
||||
Left Subscript-Superscript Subíndice y superíndice a la izquierda
|
||||
Leftwards Arrow Above Flecha hacia la izquierda arriba
|
||||
Leftwards Harpoon Above Arpón hacia la izquierda arriba
|
||||
Letter-Like Symbols Símbolos similares a letras
|
||||
Limit Límite
|
||||
Limit Example Ejemplo de límite
|
||||
Line Number Número de línea
|
||||
Linear Fraction Fracción lineal
|
||||
Logarithm Logaritmo
|
||||
Logarithm with Base Logaritmo con base
|
||||
Logical AND Y lógico
|
||||
Logical AND with Limits Y lógico con límites
|
||||
Logical OR O lógico
|
||||
Logical OR with Limits O lógico con límites
|
||||
Matrices with Brackets Matrices con corchetes
|
||||
Maximum Máximo
|
||||
Maximum Example Ejemplo de máximo
|
||||
Measured By Medido por
|
||||
Midline Dots Puntos suspensivos centrales
|
||||
Minimum Mínimo
|
||||
Minus Equal Menos igual
|
||||
Natural Logarithm Logaritmo natural
|
||||
Negated Relations Relaciones negadas
|
||||
Normal Normal
|
||||
Normal Indent Sangría normal
|
||||
Normal Table Tabla normal
|
||||
One half Un medio
|
||||
Operator Structures Estructuras de operadores
|
||||
Operators Operadores
|
||||
Outward Brackets Corchetes hacia fuera
|
||||
Overbar Barra superior
|
||||
Overbars and Underbars Barras superiores e inferiores
|
||||
Overbrace Llave superior
|
||||
Parentheses Paréntesis
|
||||
Parentheses with Separator Paréntesis con separador
|
||||
Partial Differential Diferencial parcial
|
||||
Pi Over 2 Pi sobre 2
|
||||
Plain Table 1 Tabla simple 1
|
||||
Plus Equal Más igual
|
||||
Product Producto
|
||||
Product of A Producto de A
|
||||
Product with Limits Producto con límites
|
||||
Product with Lower Limit Producto con límite inferior
|
||||
Product with Subscript-Superscript Limits Producto con límites en subíndice y superíndice
|
||||
Products and Coproducts Productos y coproductos
|
||||
Quadratic Root Raíces de la ecuación de segundo grado
|
||||
Radical with Degree Radical con índice
|
||||
Radicals Radicales
|
||||
Right Arrow with Text Above Flecha a la derecha con texto arriba
|
||||
Right Arrow with Text Below Flecha a la derecha con texto abajo
|
||||
Right Bracket Twice Dos corchetes derechos
|
||||
Rightwards Arrow Above Flecha hacia la derecha arriba
|
||||
Rightwards Harpoon Above Arpón hacia la derecha arriba
|
||||
SAMPLE MUESTRA
|
||||
Scripts Índices
|
||||
Secant Function Función secante
|
||||
Sine Function Función seno
|
||||
Sine theta Seno de theta
|
||||
Single Brackets Corchetes simples
|
||||
Single Left Angle Corchete angular izquierdo simple
|
||||
Single Left Bar Barra izquierda simple
|
||||
Single Left Brace Llave izquierda simple
|
||||
Single Left Bracket Corchete izquierdo simple
|
||||
Single Left Parenthesis Paréntesis izquierdo simple
|
||||
Single Right Brace Llave derecha simple
|
||||
Single Right Bracket Corchete derecho simple
|
||||
Single Right Parenthesis Paréntesis derecho simple
|
||||
Skewed Fraction Fracción oblicua
|
||||
Small change Pequeña variación
|
||||
Sparse Matrices Matrices dispersas
|
||||
Square Root Raíz cuadrada
|
||||
Square Root with Degree Raíz cuadrada con índice
|
||||
Square Union Unión cuadrada
|
||||
Stack Object Objeto apilado
|
||||
Stack Object (Three) Objeto apilado (tres)
|
||||
Stacked Fraction Fracción apilada
|
||||
Stacked, no bar Apilada, sin barra
|
||||
Subscript-Superscript Subíndice-superíndice
|
||||
Subscripts and Superscripts Subíndices y superíndices
|
||||
Summation Sumatoria
|
||||
Summation from 0 to n Sumatoria de 0 a n
|
||||
Summation over binomials Sumatoria sobre binomios
|
||||
Summation with Limits Sumatoria con límites
|
||||
Summation with Lower Limit Sumatoria con límite inferior
|
||||
Summation with Subscript Limit Sumatoria con límite en subíndice
|
||||
Summation with Subscript-Superscript Limits Sumatoria con límites en subíndice y superíndice
|
||||
Summations Sumatorias
|
||||
Surface Integral Integral de superficie
|
||||
Surface Integral with Limits Integral de superficie con límites
|
||||
Surface Integral with Stacked Limits Integral de superficie con límites apilados
|
||||
TOA Heading Título de tabla de autoridades
|
||||
Table Grid Cuadrícula de tabla
|
||||
Table of Figures Tabla de ilustraciones
|
||||
Tangent Function Función tangente
|
||||
Tangent formula Fórmula de la tangente
|
||||
Text over Colon Equal Texto sobre dos puntos igual
|
||||
Text over Equal Texto sobre igual
|
||||
Tilde Tilde
|
||||
Trigonometric Functions Funciones trigonométricas
|
||||
Triple Dot Tres puntos
|
||||
Triple Integral Integral triple
|
||||
Triple Integral with Limits Integral triple con límites
|
||||
Triple Integral with Stacked Limits Integral triple con límites apilados
|
||||
URGENT URGENTE
|
||||
Underbar Barra inferior
|
||||
Underbrace Llave inferior
|
||||
Union Unión
|
||||
Union of intersections Unión de intersecciones
|
||||
Union with Limits Unión con límites
|
||||
Union with Lower Limit Unión con límite inferior
|
||||
Union with Plus Unión con signo más
|
||||
Union with Subscript-Superscript Limits Unión con límites en subíndice y superíndice
|
||||
Unions and Intersections Uniones e intersecciones
|
||||
Up Diagonal Dots Puntos diagonales hacia arriba
|
||||
Vector A Vector A
|
||||
Vector AB Vector AB
|
||||
Vertical Bars Barras verticales
|
||||
Vertical Dots Puntos verticales
|
||||
Volume Integral Integral de volumen
|
||||
Volume Integral with Limits Integral de volumen con límites
|
||||
Volume Integral with Stacked Limits Integral de volumen con límites apilados
|
||||
Yields Produce
|
||||
n×n Identity Matrix Matriz identidad n×n
|
||||
n×n Matrix Matriz n×n
|
||||
tOGGLE cASE aLTERNAR mAYÚSCULAS
|
||||
x bar x con barra
|
||||
✓ None ✓ Ninguno
|
||||
preview Title Título
|
||||
Current - Professional Actual - Profesional
|
||||
Other Large Operators Otros operadores grandes
|
||||
Region: Región:
|
||||
Automatic ({region}) Automático ({region})
|
||||
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.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -353,6 +353,7 @@
|
|||
Insert Above Lisa üles
|
||||
Insert Below Lisa alla
|
||||
Insert Caption Lisa pealdis
|
||||
Insert Caption… Lisa pealdis…
|
||||
Insert Citation Lisa viide
|
||||
Insert Endnote Lisa lõpumärkus
|
||||
Insert Footnote Lisa allmärkus
|
||||
|
|
@ -663,6 +664,7 @@
|
|||
Save as Template Salvesta mallina
|
||||
Save documents Dokumentide salvestamine
|
||||
Saved Salvestatud
|
||||
Search Otsi
|
||||
Search Commands Otsi käske
|
||||
Search commands and help Otsi käske ja abi
|
||||
Search document Otsi dokumendist
|
||||
|
|
@ -778,6 +780,7 @@
|
|||
Table Design Tabeli kujundus
|
||||
Table Layout Tabeli paigutus
|
||||
Table Properties Tabeli atribuudid
|
||||
Table Properties… Tabeli atribuudid…
|
||||
Table Row Height Tabeli reakõrgus
|
||||
Table Style Options Tabelistiili valikud
|
||||
Table Styles Tabelistiilid
|
||||
|
|
@ -812,6 +815,7 @@
|
|||
Tight Tihe
|
||||
TOC Heading Sisukorra pealkiri
|
||||
TOC {n} Sisukord {n}
|
||||
Title Bar Tiitliriba
|
||||
Top Border Ülemine ääris
|
||||
Top of Page Lehe ülaserv
|
||||
Top: Ülal:
|
||||
|
|
@ -831,6 +835,10 @@
|
|||
Underline Words Only Jooni alla ainult sõnad
|
||||
Undo Võta tagasi
|
||||
Undo {action} Võta tagasi: {action}
|
||||
Undo Actions Võta toimingud tagasi
|
||||
Undo History Tagasivõtmise ajalugu
|
||||
Undo 1 Action Võta tagasi 1 toiming
|
||||
Undo {n} Actions Võta tagasi {n} toimingut
|
||||
Unformatted Text Vormindamata tekst
|
||||
Up Üles
|
||||
Update Field Uuenda välja
|
||||
|
|
@ -2091,5 +2099,13 @@
|
|||
Your system has animation effects turned on. Süsteemis on animatsiooniefektid sisse lülitatud.
|
||||
Your system has animation effects turned off. Süsteemis on animatsiooniefektid välja lülitatud.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Süsteem ei teata animatsiooniseadet, seega jäävad animatsiooniefektid sisse.
|
||||
Select Column Vali veerg
|
||||
Select Table Cells by Clicking Tabelilahtrite valimine klõpsates
|
||||
Insert Row or Column by Clicking Rea või veeru lisamine klõpsates
|
||||
Move Table by Dragging Tabeli teisaldamine lohistades
|
||||
AutoFit Column by Double-Clicking Veeru automaatne sobitus topeltklõpsuga
|
||||
Region: Piirkond:
|
||||
Automatic ({region}) Automaatne ({region})
|
||||
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.
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -101,7 +101,7 @@
|
|||
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
|
||||
|
|
@ -1627,8 +1627,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 +1649,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 +1862,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 +1882,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,7 +1902,7 @@
|
|||
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)
|
||||
|
|
@ -1929,7 +1929,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 +1968,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 +2021,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.
|
||||
|
|
@ -2061,9 +2061,25 @@
|
|||
Reading Column Width Largeur de colonne de lecture
|
||||
Reading Line Spacing Interligne de lecture
|
||||
Reading Text Size Taille du texte de lecture
|
||||
Undo Actions Annuler plusieurs actions
|
||||
Undo History Historique des annulations
|
||||
Undo 1 Action Annuler 1 action
|
||||
Undo {n} Actions Annuler {n} actions
|
||||
Start-up Démarrage
|
||||
Show the Start screen when WordCraft starts Afficher l'écran d'accueil au démarrage de WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Activé : WordCraft s'ouvre sur Fichier › Accueil, avec un document vierge, les modèles et vos documents récents. Désactivé : il ouvre un document vierge.
|
||||
Correct TWo INitial CApitals Corriger DEux MAjuscules EN début de mot
|
||||
Correct accidental usage of cAPS LOCK key Corriger l’utilisation accidentelle de la touche vERR. mAJ
|
||||
Region: Région :
|
||||
Automatic ({region}) Automatique ({region})
|
||||
Picture Image
|
||||
Pastes the clipboard's picture. Colle l'image du Presse-papiers.
|
||||
Select Column Sélectionner la colonne
|
||||
Select Table Cells by Clicking Sélectionner des cellules du tableau en cliquant
|
||||
Insert Row or Column by Clicking Insérer une ligne ou une colonne en cliquant
|
||||
Move Table by Dragging Déplacer le tableau en le faisant glisser
|
||||
AutoFit Column by Double-Clicking Ajuster la colonne en double-cliquant
|
||||
Insert Caption… Insérer une légende…
|
||||
Table Properties… Propriétés du tableau…
|
||||
Search Rechercher
|
||||
Title Bar Barre de titre
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -2451,9 +2451,25 @@
|
|||
Reading Column Width Olvasási oszlopszélesség
|
||||
Reading Line Spacing Olvasási sorköz
|
||||
Reading Text Size Olvasási szövegméret
|
||||
Undo Actions Több művelet visszavonása
|
||||
Undo History Visszavonási előzmények
|
||||
Undo 1 Action 1 művelet visszavonása
|
||||
Undo {n} Actions {n} művelet visszavonása
|
||||
Start-up Indítás
|
||||
Show the Start screen when WordCraft starts Kezdőképernyő megjelenítése a WordCraft indításakor
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Be: a WordCraft a Fájl › Kezdőlap oldalon nyílik meg, üres dokumentummal, a sablonokkal és a legutóbbi dokumentumokkal. Ki: üres dokumentumot nyit meg.
|
||||
Correct TWo INitial CApitals KÉt KEzdő NAgybetű javítása
|
||||
Correct accidental usage of cAPS LOCK key A cAPS LOCK billentyű véletlen használatának javítása
|
||||
Region: Régió:
|
||||
Automatic ({region}) Automatikus ({region})
|
||||
Picture Kép
|
||||
Pastes the clipboard's picture. Beilleszti a vágólapon lévő képet.
|
||||
Select Column Oszlop kijelölése
|
||||
Select Table Cells by Clicking Táblázatcellák kijelölése kattintással
|
||||
Insert Row or Column by Clicking Sor vagy oszlop beszúrása kattintással
|
||||
Move Table by Dragging Táblázat áthelyezése húzással
|
||||
AutoFit Column by Double-Clicking Oszlop automatikus igazítása dupla kattintással
|
||||
Insert Caption… Felirat beszúrása…
|
||||
Table Properties… Táblázat tulajdonságai…
|
||||
Search Keresés
|
||||
Title Bar Címsor
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -412,6 +412,7 @@
|
|||
Insert Above Inserisci sopra
|
||||
Insert Below Inserisci sotto
|
||||
Insert Caption Inserisci didascalia
|
||||
Insert Caption… Inserisci didascalia…
|
||||
Insert Citation Inserisci citazione
|
||||
Insert Endnote Inserisci nota di chiusura
|
||||
Insert Footnote Inserisci nota a piè di pagina
|
||||
|
|
@ -803,6 +804,7 @@
|
|||
Table Design Tabella Design
|
||||
Table Layout Tabella Layout
|
||||
Table Properties Proprietà tabella
|
||||
Table Properties… Proprietà tabella…
|
||||
Table Row Height Altezza riga tabella
|
||||
Table Style Options Opzioni stile tabella
|
||||
Table Styles Stili tabella
|
||||
|
|
@ -857,6 +859,10 @@
|
|||
Underline Words Only Sottolinea solo le parole
|
||||
Undo Annulla
|
||||
Undo {action} Annulla {action}
|
||||
Undo Actions Annulla azioni
|
||||
Undo History Cronologia annullamenti
|
||||
Undo 1 Action Annulla 1 azione
|
||||
Undo {n} Actions Annulla {n} azioni
|
||||
Unformatted Text Testo non formattato
|
||||
Up Su
|
||||
Update Field Aggiorna campo
|
||||
|
|
@ -2321,6 +2327,11 @@
|
|||
Radicals Radicali
|
||||
Recipient lists Elenchi destinatari
|
||||
Resize Table by Dragging Ridimensiona la tabella trascinando
|
||||
Select Column Seleziona colonna
|
||||
Select Table Cells by Clicking Seleziona celle della tabella con un clic
|
||||
Insert Row or Column by Clicking Inserisci riga o colonna con un clic
|
||||
Move Table by Dragging Sposta la tabella trascinando
|
||||
AutoFit Column by Double-Clicking Adatta la colonna con un doppio clic
|
||||
Right Arrow with Text Above Freccia a destra con testo sopra
|
||||
Right Arrow with Text Below Freccia a destra con testo sotto
|
||||
Right Bracket Twice Parentesi quadra destra due volte
|
||||
|
|
@ -2422,6 +2433,9 @@
|
|||
✓ None ✓ Nessuno
|
||||
preview Title Esempio
|
||||
Do Not Check Spelling or Grammar Non controllare ortografia e grammatica
|
||||
Start-up Avvio
|
||||
Show the Start screen when WordCraft starts Mostra la schermata iniziale all'avvio di WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Attivo: WordCraft si apre su File › Home, con un documento vuoto, i modelli e i documenti recenti. Disattivo: apre un documento vuoto.
|
||||
Couldn't make WordCraft the default: {error} Impossibile rendere WordCraft l'app predefinita: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. In App predefinite, scegli WordCraft per i tipi di documento che deve aprire.
|
||||
Make WordCraft the default for Word documents… Rendi WordCraft l'app predefinita per i documenti Word…
|
||||
|
|
@ -2443,3 +2457,5 @@ preview Title Esempio
|
|||
Correct accidental usage of cAPS LOCK key Correggi l'uso accidentale del tasto bLOC mAIUSC
|
||||
Region: Area geografica:
|
||||
Automatic ({region}) Automatico ({region})
|
||||
Search Cerca
|
||||
Title Bar Barra del titolo
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -155,6 +155,7 @@
|
|||
Right (vertical) 右 (縦書き)
|
||||
Ruby text: ルビ文字:
|
||||
Save documents ドキュメントの保存
|
||||
Search 検索
|
||||
Search for templates テンプレートを検索
|
||||
Show all revisions inline すべての変更履歴を本文中に表示
|
||||
Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 表示 › モードの切り替えと同様に、文書の色を反転して表示します。保存、印刷、PDF は変わりません。
|
||||
|
|
@ -198,6 +199,7 @@
|
|||
Page Border ページ罫線の設定
|
||||
Apply to: 適用先:
|
||||
These marks show even when Show/Hide ¶ is off. これらの記号は、[編集記号の表示/非表示 ¶] がオフでも表示されます。
|
||||
Title Bar タイトル バー
|
||||
Typing replaces selected text 入力時に選択文字列を置き換える
|
||||
Update fields before printing 印刷前にフィールドを更新する
|
||||
Use the Insert key to switch overtype on and off Insert キーで上書きのオンとオフを切り替える
|
||||
|
|
@ -469,6 +471,7 @@
|
|||
Insert Above 上に行を挿入
|
||||
Insert Below 下に行を挿入
|
||||
Insert Caption 図表番号の挿入
|
||||
Insert Caption… 図表番号の挿入…
|
||||
Insert Citation 引用文献の挿入
|
||||
Insert Endnote 文末脚注の挿入
|
||||
Insert Footnote 脚注の挿入
|
||||
|
|
@ -858,6 +861,7 @@
|
|||
Table of Contents 目次
|
||||
Table of\nContents 目次
|
||||
Table Properties 表のプロパティ
|
||||
Table Properties… 表のプロパティ…
|
||||
Table Row Height 表の行の高さ
|
||||
Table Style Options 表スタイルのオプション
|
||||
Table Styles 表のスタイル
|
||||
|
|
@ -907,6 +911,10 @@
|
|||
Underline Words Only 単語のみ下線
|
||||
Undo 元に戻す
|
||||
Undo {action} 元に戻す: {action}
|
||||
Undo Actions 複数の操作を元に戻す
|
||||
Undo History 元に戻す履歴
|
||||
Undo 1 Action 1 件の操作を元に戻す
|
||||
Undo {n} Actions {n} 件の操作を元に戻す
|
||||
Ungroup グループ解除
|
||||
Up 上
|
||||
Update Field フィールドの更新
|
||||
|
|
@ -2057,5 +2065,13 @@
|
|||
Your system has animation effects turned on. システムのアニメーション効果はオンです。
|
||||
Your system has animation effects turned off. システムのアニメーション効果はオフです。
|
||||
Your system doesn't report an animation setting, so animation effects stay on. システムからアニメーションの設定を取得できないため、アニメーション効果はオンのままです。
|
||||
Select Column 列の選択
|
||||
Select Table Cells by Clicking クリックで表のセルを選択
|
||||
Insert Row or Column by Clicking クリックで行または列を挿入
|
||||
Move Table by Dragging ドラッグで表を移動
|
||||
AutoFit Column by Double-Clicking ダブルクリックで列の幅を自動調整
|
||||
Region: 地域:
|
||||
Automatic ({region}) 自動 ({region})
|
||||
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 は [ファイル] › [ホーム] で開き、白紙の文書、テンプレート、最近使った文書を表示します。オフ: 白紙の文書を開きます。
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -336,6 +336,7 @@
|
|||
Insert Above Wstaw powyżej
|
||||
Insert Below Wstaw poniżej
|
||||
Insert Caption Wstaw podpis
|
||||
Insert Caption… Wstaw podpis…
|
||||
Insert Citation Wstaw cytat
|
||||
Insert Endnote Wstaw przypis końcowy
|
||||
Insert Footnote Wstaw przypis dolny
|
||||
|
|
@ -719,6 +720,7 @@
|
|||
Table Design Projekt tabeli
|
||||
Table Layout Układ tabeli
|
||||
Table Properties Właściwości tabeli
|
||||
Table Properties… Właściwości tabeli…
|
||||
Table Row Height Wysokość wiersza tabeli
|
||||
Table Style Options Opcje stylu tabeli
|
||||
Table Styles Style tabeli
|
||||
|
|
@ -1946,6 +1948,11 @@
|
|||
Reset Graphic Resetuj grafikę
|
||||
Reset\nGraphic Resetuj\ngrafikę
|
||||
Resize Table by Dragging Zmień rozmiar tabeli przeciąganiem
|
||||
Select Column Zaznacz kolumnę tabeli
|
||||
Select Table Cells by Clicking Zaznacz komórki tabeli kliknięciem
|
||||
Insert Row or Column by Clicking Wstaw wiersz lub kolumnę kliknięciem
|
||||
Move Table by Dragging Przenieś tabelę przeciąganiem
|
||||
AutoFit Column by Double-Clicking Dopasuj kolumnę dwukrotnym kliknięciem
|
||||
Revised document Dokument poprawiony
|
||||
Rich Text Content Control Formant zawartości tekstu sformatowanego
|
||||
Sat: Nasycenie:
|
||||
|
|
@ -2055,6 +2062,13 @@
|
|||
{name} already exists. Choose Save again to replace it. Plik {name} już istnieje. Wybierz ponownie Zapisz, aby go zastąpić.
|
||||
{name} wasn't found. Nie znaleziono: {name}.
|
||||
“{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nie jest liczbą.
|
||||
Undo Actions Cofnij kilka akcji
|
||||
Undo History Historia cofania
|
||||
Undo 1 Action Cofnij 1 akcję
|
||||
Undo {n} Actions Cofnij akcje: {n}
|
||||
Start-up Uruchamianie
|
||||
Show the Start screen when WordCraft starts Pokazuj ekran startowy po uruchomieniu programu WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Włączone: WordCraft otwiera się na karcie Plik › Start z pustym dokumentem, szablonami i ostatnimi dokumentami. Wyłączone: otwiera pusty dokument.
|
||||
Couldn't make WordCraft the default: {error} Nie udało się ustawić WordCraft jako domyślnego: {error}
|
||||
In Default apps, choose WordCraft for the document types you want it to open. W Aplikacjach domyślnych wybierz WordCraft dla typów dokumentów, które ma otwierać.
|
||||
Make WordCraft the default for Word documents… Ustaw WordCraft jako domyślny dla dokumentów Word…
|
||||
|
|
@ -2100,3 +2114,357 @@
|
|||
Correct accidental usage of cAPS LOCK key Poprawiaj przypadkowe użycie klawisza cAPS LOCK
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatycznie ({region})
|
||||
|
||||
# Style, table style, watermark, paper size and equation gallery names, from #750 by @olekkut
|
||||
AaBbCcDd AaBbCcDd
|
||||
Annotation Reference Odwołanie do adnotacji
|
||||
Annotation Subject Temat adnotacji
|
||||
Annotation Text Tekst adnotacji
|
||||
Body text in a short paragraph to show spacing. Treść dokumentu w krótkim akapicie pokazującym odstępy.
|
||||
Default Paragraph Font Domyślna czcionka akapitu
|
||||
Envelope Address Adres na kopercie
|
||||
Envelope Return Adres zwrotny na kopercie
|
||||
Index Heading Nagłówek indeksu
|
||||
Index {n} Indeks {n}
|
||||
Line Number Numer wiersza
|
||||
Normal Normalny
|
||||
Normal Indent Normalny z wcięciem
|
||||
Normal Table Normalna tabela
|
||||
Table Grid Siatka tabeli
|
||||
Table of Figures Spis ilustracji
|
||||
preview Title Tytuł
|
||||
TOA Heading Nagłówek spisu źródeł
|
||||
Good evening Dobry wieczór
|
||||
Good morning Dzień dobry
|
||||
Good afternoon Dzień dobry
|
||||
tOGGLE cASE oDWRÓĆ wIELKOŚĆ lITER
|
||||
Change to Level 1 Zmień na poziom 1
|
||||
Change to Level 2 Zmień na poziom 2
|
||||
Change to Level 3 Zmień na poziom 3
|
||||
Change to Level 4 Zmień na poziom 4
|
||||
Change to Level 5 Zmień na poziom 5
|
||||
Level 1 Poziom 1
|
||||
Level 2 Poziom 2
|
||||
Level 3 Poziom 3
|
||||
ASAP JAK NAJSZYBCIEJ
|
||||
CONFIDENTIAL POUFNE
|
||||
DO NOT COPY NIE KOPIOWAĆ
|
||||
DRAFT WERSJA ROBOCZA
|
||||
SAMPLE WZÓR
|
||||
URGENT PILNE
|
||||
Letter 8.50" × 11.00" Letter 8,50" × 11,00"
|
||||
Legal 8.50" × 14.00" Legal 8,50" × 14,00"
|
||||
Executive 7.25" × 10.50" Executive 7,25" × 10,50"
|
||||
Statement 5.50" × 8.50" Statement 5,50" × 8,50"
|
||||
Tabloid 11.00" × 17.00" Tabloid 11,00" × 17,00"
|
||||
B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33"
|
||||
B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12"
|
||||
Envelope #10 4.12" × 9.50" Koperta nr 10 4,12" × 9,50"
|
||||
Envelope DL 4.33" × 8.66" Koperta DL 4,33" × 8,66"
|
||||
Plain Table 1 Prosta tabela 1
|
||||
Grid Table 1 Light Tabela z siatką 1 — jasna
|
||||
Grid Table 4 – Blue Tabela z siatką 4 — niebieska
|
||||
Grid Table 4 – Green Tabela z siatką 4 — zielona
|
||||
Grid Table 4 – Orange Tabela z siatką 4 — pomarańczowa
|
||||
Grid Table 4 – Purple Tabela z siatką 4 — fioletowa
|
||||
List Table 3 – Blue Tabela listy 3 — niebieska
|
||||
List Table 3 – Green Tabela listy 3 — zielona
|
||||
List Table 3 – Orange Tabela listy 3 — pomarańczowa
|
||||
List Table 3 – Purple Tabela listy 3 — fioletowa
|
||||
Convert between built-up and linear form Konwertuj między postacią profesjonalną a liniową
|
||||
Justification of a display equation Wyrównanie równania wyświetlanego w osobnym wierszu
|
||||
Number the equation; the number sits at the right margin (or type # then the number) Numeruj równanie; numer jest umieszczany przy prawym marginesie (lub wpisz # i numer)
|
||||
Show the equation in the line of text Wyświetl równanie w wierszu tekstu
|
||||
Show the equation on a line of its own Wyświetl równanie w osobnym wierszu
|
||||
Type equations in LaTeX Wpisuj równania w notacji LaTeX
|
||||
Type equations in the linear format (UnicodeMath) Wpisuj równania w formacie liniowym (UnicodeMath)
|
||||
Type normal (non-math) text in the equation Wpisz zwykły (niematematyczny) tekst w równaniu
|
||||
Sides of a right triangle Boki trójkąta prostokątnego
|
||||
Circle area from its radius Pole koła na podstawie promienia
|
||||
Roots of ax²+bx+c Pierwiastki równania ax²+bx+c
|
||||
Binomial expansion of (x+a)ⁿ Rozwinięcie dwumianu (x+a)ⁿ
|
||||
Power series of (1+x)ⁿ Szereg potęgowy (1+x)ⁿ
|
||||
Euler's identity Tożsamość Eulera
|
||||
Derivative as a limit Pochodna jako granica
|
||||
Exponential series Szereg wykładniczy
|
||||
Integral of e^(−x²) Całka z e^(−x²)
|
||||
Sine and cosine series of f(x) Szereg sinusów i cosinusów funkcji f(x)
|
||||
Sum or difference of sines Suma lub różnica sinusów
|
||||
Sum of cosines Suma cosinusów
|
||||
Normal probability density Gęstość rozkładu normalnego
|
||||
Bayes' rule Twierdzenie Bayesa
|
||||
Accents Akcenty
|
||||
Basic Math Podstawowa matematyka
|
||||
Boxed Formulas Wzory w ramce
|
||||
Cases and Stacks Układy klamrowe i piętrowe
|
||||
Common Accent Objects Typowe obiekty z akcentem
|
||||
Common Brackets Typowe nawiasy
|
||||
Common Fractions Typowe ułamki
|
||||
Common Functions Typowe funkcje
|
||||
Common Large Operators Typowe duże operatory
|
||||
Common Operator Structures Typowe struktury operatorów
|
||||
Common Operators Typowe operatory
|
||||
Common Radicals Typowe pierwiastki
|
||||
Common Subscripts and Superscripts Typowe indeksy dolne i górne
|
||||
Contour Integrals Całki okrężne
|
||||
Differentials Różniczki
|
||||
Dots Kropki
|
||||
Empty Matrices Puste macierze
|
||||
Functions Funkcje
|
||||
Geometry Geometria
|
||||
Greek Letters Litery greckie
|
||||
Hyperbolic Functions Funkcje hiperboliczne
|
||||
Identity Matrices Macierze jednostkowe
|
||||
Integrals Całki
|
||||
Inverse Functions Funkcje odwrotne
|
||||
Inverse Hyperbolic Functions Odwrotne funkcje hiperboliczne
|
||||
Letter-Like Symbols Symbole literopodobne
|
||||
Matrices with Brackets Macierze w nawiasach
|
||||
Negated Relations Relacje zanegowane
|
||||
Operator Structures Struktury operatorów
|
||||
Operators Operatory
|
||||
Other Large Operators Inne duże operatory
|
||||
Overbars and Underbars Kreski górne i dolne
|
||||
Products and Coproducts Iloczyny i koprodukty
|
||||
Radicals Pierwiastki
|
||||
Scripts Indeksy
|
||||
Single Brackets Pojedyncze nawiasy
|
||||
Sparse Matrices Macierze rzadkie
|
||||
Subscripts and Superscripts Indeksy dolne i górne
|
||||
Summations Sumy
|
||||
Trigonometric Functions Funkcje trygonometryczne
|
||||
Unions and Intersections Sumy i iloczyny zbiorów
|
||||
1×2 Empty Matrix Pusta macierz 1×2
|
||||
1×3 Empty Matrix Pusta macierz 1×3
|
||||
2×1 Empty Matrix Pusta macierz 2×1
|
||||
2×2 Empty Matrix Pusta macierz 2×2
|
||||
2×3 Empty Matrix Pusta macierz 2×3
|
||||
3×1 Empty Matrix Pusta macierz 3×1
|
||||
3×2 Empty Matrix Pusta macierz 3×2
|
||||
3×3 Empty Matrix Pusta macierz 3×3
|
||||
2×2 Determinant Wyznacznik 2×2
|
||||
2×2 Identity Matrix Macierz jednostkowa 2×2
|
||||
2×2 Identity Matrix (sparse) Macierz jednostkowa 2×2 (rzadka)
|
||||
3×3 Identity Matrix Macierz jednostkowa 3×3
|
||||
3×3 Identity Matrix (sparse) Macierz jednostkowa 3×3 (rzadka)
|
||||
2×2 Matrix in Brackets Macierz 2×2 w nawiasach kwadratowych
|
||||
2×2 Matrix in Double Bars Macierz 2×2 w podwójnych kreskach pionowych
|
||||
2×2 Matrix in Parentheses Macierz 2×2 w nawiasach okrągłych
|
||||
n×n Identity Matrix Macierz jednostkowa n×n
|
||||
n×n Matrix Macierz n×n
|
||||
A xor B A xor B
|
||||
Absolute value cases Przypadki wartości bezwzględnej
|
||||
Acute Akcent ostry
|
||||
Angle Brackets Nawiasy kątowe
|
||||
Angle Brackets with Separator Nawiasy kątowe z separatorem
|
||||
Angle Brackets with Two Separators Nawiasy kątowe z dwoma separatorami
|
||||
Baseline Dots Kropki na linii bazowej
|
||||
Binomial Coefficient Współczynnik dwumianowy
|
||||
Binomial Coefficient (angle) Współczynnik dwumianowy (nawiasy kątowe)
|
||||
Binomial Coefficient with Angle Brackets Współczynnik dwumianowy w nawiasach kątowych
|
||||
Boxed Formula Wzór w ramce
|
||||
Boxed Formula (example) Wzór w ramce (przykład)
|
||||
Braces Nawiasy klamrowe
|
||||
Braces with Separator Nawiasy klamrowe z separatorem
|
||||
Brackets Nawiasy kwadratowe
|
||||
Brackets with Separators Nawiasy kwadratowe z separatorami
|
||||
Breve Brewis
|
||||
Cases (Three Conditions) Układ warunków (trzy warunki)
|
||||
Cases (Two Conditions) Układ warunków (dwa warunki)
|
||||
Ceiling Sufit
|
||||
Change in y over change in x Przyrost y przez przyrost x
|
||||
Check Daszek odwrócony
|
||||
Circled Dot Kropka w okręgu
|
||||
Circled Plus Plus w okręgu
|
||||
Circled Times Znak mnożenia w okręgu
|
||||
Colon Equal Dwukropek i znak równości
|
||||
Contour Integral Całka okrężna
|
||||
Contour Integral with Limits Całka okrężna z granicami
|
||||
Contour Integral with Stacked Limits Całka okrężna z granicami u góry i u dołu
|
||||
Coproduct Koprodukt
|
||||
Coproduct with Limits Koprodukt z granicami
|
||||
Coproduct with Lower Limit Koprodukt z dolną granicą
|
||||
Coproduct with Subscript-Superscript Limits Koprodukt z granicami w indeksach
|
||||
Cosecant Function Funkcja cosecans
|
||||
Cosine 2x Cosinus 2x
|
||||
Cosine Function Funkcja cosinus
|
||||
Cotangent Function Funkcja cotangens
|
||||
Cube Root Pierwiastek sześcienny
|
||||
Delta Equal To Równe z deltą
|
||||
Delta over arrow Delta nad strzałką
|
||||
Diagonal Dots Kropki ukośne
|
||||
Differential Różniczka
|
||||
Differential theta Różniczka theta
|
||||
Differential x Różniczka x
|
||||
Differential y Różniczka y
|
||||
Dot Kropka nad znakiem
|
||||
Double Bar Podwójna kreska nad znakiem
|
||||
Double Dot Dwie kropki nad znakiem
|
||||
Double Integral Całka podwójna
|
||||
Double Integral with Limits Całka podwójna z granicami
|
||||
Double Integral with Stacked Limits Całka podwójna z granicami u góry i u dołu
|
||||
Double Left Arrow with Text Above Podwójna strzałka w lewo z tekstem powyżej
|
||||
Double Left Arrow with Text Below Podwójna strzałka w lewo z tekstem poniżej
|
||||
Double Left Right Arrow with Text Above Podwójna strzałka w lewo i prawo z tekstem powyżej
|
||||
Double Left Right Arrow with Text Below Podwójna strzałka w lewo i prawo z tekstem poniżej
|
||||
Double Right Arrow with Text Above Podwójna strzałka w prawo z tekstem powyżej
|
||||
Double Right Arrow with Text Below Podwójna strzałka w prawo z tekstem poniżej
|
||||
Double Square Brackets Podwójne nawiasy kwadratowe
|
||||
Double Vertical Bars Podwójne kreski pionowe
|
||||
Double summation Suma podwójna
|
||||
Equal Equal Podwójny znak równości
|
||||
Equal by Definition Równe z definicji
|
||||
Exponential Funkcja wykładnicza
|
||||
Floor Podłoga
|
||||
Grave Akcent ciężki
|
||||
Grouping Character Above Znak grupujący powyżej
|
||||
Grouping Character Below Znak grupujący poniżej
|
||||
Hat Daszek
|
||||
Hyperbolic Cosecant Function Funkcja cosecans hiperboliczny
|
||||
Hyperbolic Cosine Function Funkcja cosinus hiperboliczny
|
||||
Hyperbolic Cotangent Function Funkcja cotangens hiperboliczny
|
||||
Hyperbolic Secant Function Funkcja secans hiperboliczny
|
||||
Hyperbolic Sine Function Funkcja sinus hiperboliczny
|
||||
Hyperbolic Tangent Function Funkcja tangens hiperboliczny
|
||||
Hypotenuse Przeciwprostokątna
|
||||
Integral with Limits Całka z granicami
|
||||
Integral with Stacked Limits Całka z granicami u góry i u dołu
|
||||
Intersection Iloczyn zbiorów
|
||||
Intersection with Limits Iloczyn zbiorów z granicami
|
||||
Intersection with Lower Limit Iloczyn zbiorów z dolną granicą
|
||||
Intersection with Subscript-Superscript Limits Iloczyn zbiorów z granicami w indeksach
|
||||
Inverse Cosecant Function Funkcja arcus cosecans
|
||||
Inverse Cosine Function Funkcja arcus cosinus
|
||||
Inverse Cotangent Function Funkcja arcus cotangens
|
||||
Inverse Hyperbolic Cosecant Function Funkcja area cosecans hiperboliczny
|
||||
Inverse Hyperbolic Cosine Function Funkcja area cosinus hiperboliczny
|
||||
Inverse Hyperbolic Cotangent Function Funkcja area cotangens hiperboliczny
|
||||
Inverse Hyperbolic Secant Function Funkcja area secans hiperboliczny
|
||||
Inverse Hyperbolic Sine Function Funkcja area sinus hiperboliczny
|
||||
Inverse Hyperbolic Tangent Function Funkcja area tangens hiperboliczny
|
||||
Inverse Secant Function Funkcja arcus secans
|
||||
Inverse Sine Function Funkcja arcus sinus
|
||||
Inverse Tangent Function Funkcja arcus tangens
|
||||
Left Arrow with Text Above Strzałka w lewo z tekstem powyżej
|
||||
Left Arrow with Text Below Strzałka w lewo z tekstem poniżej
|
||||
Left Bracket Twice Podwójny lewy nawias kwadratowy
|
||||
Left Right Arrow Above Strzałka w lewo i prawo powyżej
|
||||
Left Right Arrow with Text Above Strzałka w lewo i prawo z tekstem powyżej
|
||||
Left Right Arrow with Text Below Strzałka w lewo i prawo z tekstem poniżej
|
||||
Left Subscript-Superscript Lewy indeks dolny i górny
|
||||
Leftwards Arrow Above Strzałka w lewo powyżej
|
||||
Leftwards Harpoon Above Harpun w lewo powyżej
|
||||
Limit Granica
|
||||
Limit Example Przykład granicy
|
||||
Linear Fraction Ułamek liniowy
|
||||
Logarithm Logarytm
|
||||
Logarithm with Base Logarytm o podanej podstawie
|
||||
Logical AND Koniunkcja logiczna
|
||||
Logical AND with Limits Koniunkcja logiczna z granicami
|
||||
Logical OR Alternatywa logiczna
|
||||
Logical OR with Limits Alternatywa logiczna z granicami
|
||||
Maximum Maksimum
|
||||
Maximum Example Przykład maksimum
|
||||
Measured By Mierzone przez
|
||||
Midline Dots Kropki środkowe
|
||||
Minimum Minimum
|
||||
Minus Equal Minus i znak równości
|
||||
Natural Logarithm Logarytm naturalny
|
||||
One half Jedna druga
|
||||
Outward Brackets Nawiasy kwadratowe skierowane na zewnątrz
|
||||
Overbar Kreska górna
|
||||
Overbrace Klamra górna
|
||||
Parentheses Nawiasy okrągłe
|
||||
Parentheses with Separator Nawiasy okrągłe z separatorem
|
||||
Partial Differential Różniczka cząstkowa
|
||||
Pi Over 2 Pi przez 2
|
||||
Plus Equal Plus i znak równości
|
||||
Product Iloczyn
|
||||
Product of A Iloczyn A
|
||||
Product with Limits Iloczyn z granicami
|
||||
Product with Lower Limit Iloczyn z dolną granicą
|
||||
Product with Subscript-Superscript Limits Iloczyn z granicami w indeksach
|
||||
Quadratic Root Pierwiastek równania kwadratowego
|
||||
Radical with Degree Pierwiastek ze stopniem
|
||||
Right Arrow with Text Above Strzałka w prawo z tekstem powyżej
|
||||
Right Arrow with Text Below Strzałka w prawo z tekstem poniżej
|
||||
Right Bracket Twice Podwójny prawy nawias kwadratowy
|
||||
Rightwards Arrow Above Strzałka w prawo powyżej
|
||||
Rightwards Harpoon Above Harpun w prawo powyżej
|
||||
Secant Function Funkcja secans
|
||||
Sine Function Funkcja sinus
|
||||
Sine theta Sinus theta
|
||||
Single Left Angle Pojedynczy lewy nawias kątowy
|
||||
Single Left Bar Pojedyncza lewa kreska pionowa
|
||||
Single Left Brace Pojedynczy lewy nawias klamrowy
|
||||
Single Left Bracket Pojedynczy lewy nawias kwadratowy
|
||||
Single Left Parenthesis Pojedynczy lewy nawias okrągły
|
||||
Single Right Brace Pojedynczy prawy nawias klamrowy
|
||||
Single Right Bracket Pojedynczy prawy nawias kwadratowy
|
||||
Single Right Parenthesis Pojedynczy prawy nawias okrągły
|
||||
Skewed Fraction Ułamek ukośny
|
||||
Small change Mały przyrost
|
||||
Square Root Pierwiastek kwadratowy
|
||||
Square Root with Degree Pierwiastek dowolnego stopnia
|
||||
Square Union Kwadratowa suma zbiorów
|
||||
Stack Object Obiekt piętrowy
|
||||
Stack Object (Three) Obiekt piętrowy (trzy poziomy)
|
||||
Stacked Fraction Ułamek piętrowy
|
||||
Stacked, no bar Układ piętrowy bez kreski
|
||||
Subscript-Superscript Indeks dolny i górny
|
||||
Summation Suma
|
||||
Summation from 0 to n Suma od 0 do n
|
||||
Summation over binomials Suma po dwumianach
|
||||
Summation with Limits Suma z granicami
|
||||
Summation with Lower Limit Suma z dolną granicą
|
||||
Summation with Subscript Limit Suma z granicą w indeksie dolnym
|
||||
Summation with Subscript-Superscript Limits Suma z granicami w indeksach
|
||||
Surface Integral Całka powierzchniowa
|
||||
Surface Integral with Limits Całka powierzchniowa z granicami
|
||||
Surface Integral with Stacked Limits Całka powierzchniowa z granicami u góry i u dołu
|
||||
Tangent Function Funkcja tangens
|
||||
Tangent formula Wzór na tangens
|
||||
Text over Colon Equal Tekst nad znakiem przypisania
|
||||
Text over Equal Tekst nad znakiem równości
|
||||
Tilde Tylda
|
||||
Triple Dot Trzy kropki nad znakiem
|
||||
Triple Integral Całka potrójna
|
||||
Triple Integral with Limits Całka potrójna z granicami
|
||||
Triple Integral with Stacked Limits Całka potrójna z granicami u góry i u dołu
|
||||
Underbar Kreska dolna
|
||||
Underbrace Klamra dolna
|
||||
Union Suma zbiorów
|
||||
Union of intersections Suma iloczynów zbiorów
|
||||
Union with Limits Suma zbiorów z granicami
|
||||
Union with Lower Limit Suma zbiorów z dolną granicą
|
||||
Union with Plus Suma zbiorów z plusem
|
||||
Union with Subscript-Superscript Limits Suma zbiorów z granicami w indeksach
|
||||
Up Diagonal Dots Kropki ukośne w górę
|
||||
Vector A Wektor A
|
||||
Vector AB Wektor AB
|
||||
Vertical Bars Kreski pionowe
|
||||
Vertical Dots Kropki pionowe
|
||||
Volume Integral Całka objętościowa
|
||||
Volume Integral with Limits Całka objętościowa z granicami
|
||||
Volume Integral with Stacked Limits Całka objętościowa z granicami u góry i u dołu
|
||||
Yields Daje w wyniku
|
||||
x bar x z kreską
|
||||
All - Linear Wszystkie — liniowe
|
||||
All - Professional Wszystkie — profesjonalne
|
||||
Current - Linear Bieżące — liniowe
|
||||
Current - Professional Bieżące — profesjonalne
|
||||
Centered as Group Wyśrodkowane jako grupa
|
||||
✓ None ✓ Brak
|
||||
Change to Level 6 Zmień na poziom 6
|
||||
Change to Level 7 Zmień na poziom 7
|
||||
Change to Level 8 Zmień na poziom 8
|
||||
Change to Level 9 Zmień na poziom 9
|
||||
Level 4 Poziom 4
|
||||
Level 5 Poziom 5
|
||||
Level 6 Poziom 6
|
||||
Level 7 Poziom 7
|
||||
Level 8 Poziom 8
|
||||
Level 9 Poziom 9
|
||||
Typing Pisanie
|
||||
Search Wyszukaj
|
||||
Title Bar Pasek tytułu
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -162,6 +162,7 @@
|
|||
Border Painter Pincel de bordas
|
||||
Border\nPainter Pincel\nde bordas
|
||||
Borders Bordas
|
||||
Search Pesquisar
|
||||
Show the mini toolbar when text is selected Mostrar a minibarra de ferramentas ao selecionar texto
|
||||
Spaces Espaços
|
||||
Spelling Ortografia
|
||||
|
|
@ -178,6 +179,7 @@
|
|||
Page Border Borda da página
|
||||
Apply to: Aplicar a:
|
||||
These marks show even when Show/Hide ¶ is off. Estas marcas aparecem mesmo com Mostrar/ocultar ¶ desativado.
|
||||
Title Bar Barra de título
|
||||
Typing replaces selected text O que você digita substitui o texto selecionado
|
||||
Update fields before printing Atualizar campos antes de imprimir
|
||||
Use the Insert key to switch overtype on and off Usar a tecla Insert para ativar e desativar a sobrescrita
|
||||
|
|
@ -470,6 +472,7 @@
|
|||
Insert Above Inserir acima
|
||||
Insert Below Inserir abaixo
|
||||
Insert Caption Inserir legenda
|
||||
Insert Caption… Inserir legenda…
|
||||
Insert Citation Inserir citação
|
||||
Insert Endnote Inserir nota de fim
|
||||
Insert Footnote Inserir nota de rodapé
|
||||
|
|
@ -859,6 +862,7 @@
|
|||
Table of Contents Sumário
|
||||
Table of\nContents Sumário
|
||||
Table Properties Propriedades da tabela
|
||||
Table Properties… Propriedades da tabela…
|
||||
Table Row Height Altura da linha da tabela
|
||||
Table Style Options Opções de estilo de tabela
|
||||
Table Styles Estilos de tabela
|
||||
|
|
@ -907,6 +911,10 @@
|
|||
Underline Words Only Sublinhar somente palavras
|
||||
Undo Desfazer
|
||||
Undo {action} Desfazer {action}
|
||||
Undo Actions Desfazer ações
|
||||
Undo History Histórico de desfazer
|
||||
Undo 1 Action Desfazer 1 ação
|
||||
Undo {n} Actions Desfazer {n} ações
|
||||
Ungroup Desagrupar
|
||||
Up Para cima
|
||||
Update Field Atualizar campo
|
||||
|
|
@ -2069,5 +2077,13 @@
|
|||
Your system has animation effects turned on. Seu sistema está com os efeitos de animação ativados.
|
||||
Your system has animation effects turned off. Seu sistema está com os efeitos de animação desativados.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Seu sistema não informa uma configuração de animação, então os efeitos de animação continuam ativados.
|
||||
Select Column Selecionar coluna
|
||||
Select Table Cells by Clicking Selecionar células da tabela clicando
|
||||
Insert Row or Column by Clicking Inserir linha ou coluna clicando
|
||||
Move Table by Dragging Mover tabela arrastando
|
||||
AutoFit Column by Double-Clicking Autoajustar coluna com clique duplo
|
||||
Region: Região:
|
||||
Automatic ({region}) Automático ({region})
|
||||
Start-up Inicialização
|
||||
Show the Start screen when WordCraft starts Mostrar a tela inicial ao abrir o WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Ativado: o WordCraft abre em Arquivo › Início, com um documento em branco, os modelos e seus documentos recentes. Desativado: abre um documento em branco.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -189,6 +189,8 @@
|
|||
Delete Style Удалить стиль
|
||||
Delete Table Удалить таблицу
|
||||
Design Дизайн
|
||||
Search Поиск
|
||||
Title Bar Строка заголовка
|
||||
development build сборка для разработки
|
||||
Diagonal Диагональ
|
||||
Diamond Ромб
|
||||
|
|
@ -341,6 +343,7 @@
|
|||
Insert Above Вставить сверху
|
||||
Insert Below Вставить снизу
|
||||
Insert Caption Вставить подпись
|
||||
Insert Caption… Вставить подпись…
|
||||
Insert Citation Вставить ссылку на источник
|
||||
Insert Endnote Вставить концевую сноску
|
||||
Insert Footnote Вставить сноску
|
||||
|
|
@ -728,6 +731,7 @@
|
|||
Table of Figures Список иллюстраций
|
||||
Table of\nContents Оглавление
|
||||
Table Properties Свойства таблицы
|
||||
Table Properties… Свойства таблицы…
|
||||
Table Row Height Высота строки таблицы
|
||||
Table Style Options Параметры стилей таблиц
|
||||
Table Styles Стили таблиц
|
||||
|
|
@ -778,6 +782,10 @@ preview Title Пример
|
|||
Underline Words Only Подчёркивать только слова
|
||||
Undo Отменить
|
||||
Undo {action} Отменить: {action}
|
||||
Undo Actions Отменить действия
|
||||
Undo History Журнал отмены
|
||||
Undo 1 Action Отменить действие
|
||||
Undo {n} Actions Отменить действий: {n}
|
||||
Up Вверх
|
||||
Update Field Обновить поле
|
||||
Update Index Обновить указатель
|
||||
|
|
@ -2449,5 +2457,20 @@ preview Title Пример
|
|||
Your system has animation effects turned on. В системе эффекты анимации включены.
|
||||
Your system has animation effects turned off. В системе эффекты анимации выключены.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Система не сообщает о настройке анимации, поэтому эффекты анимации остаются включёнными.
|
||||
Select Column Выделить столбец таблицы
|
||||
Select Table Cells by Clicking Выделить ячейки таблицы щелчком
|
||||
Insert Row or Column by Clicking Вставить строку или столбец щелчком
|
||||
Move Table by Dragging Переместить таблицу перетаскиванием
|
||||
AutoFit Column by Double-Clicking Автоподбор ширины столбца двойным щелчком
|
||||
Region: Регион:
|
||||
Automatic ({region}) Автоматически ({region})
|
||||
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 открывается на странице Файл › Главная с пустым документом, шаблонами и недавними документами. Выкл.: открывается пустой документ.
|
||||
Typing Ввод
|
||||
Level 4 Уровень 4
|
||||
Level 5 Уровень 5
|
||||
Level 6 Уровень 6
|
||||
Level 7 Уровень 7
|
||||
Level 8 Уровень 8
|
||||
Level 9 Уровень 9
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -204,6 +204,7 @@
|
|||
Preview Pregled
|
||||
Remove Password Ukloni lozinku
|
||||
Save documents Čuvanje dokumenata
|
||||
Search Pretraga
|
||||
Shadow, glow and soft edges Senka, sjaj i meke ivice
|
||||
Shape Effects Efekti oblika
|
||||
Show all revisions inline Prikaži sve izmene u tekstu
|
||||
|
|
@ -255,6 +256,7 @@
|
|||
Page Border Ivica strane
|
||||
Apply to: Primeni na:
|
||||
These marks show even when Show/Hide ¶ is off. Ove oznake se prikazuju i kada je Prikaži/sakrij ¶ isključeno.
|
||||
Title Bar Naslovna traka
|
||||
Typing replaces selected text Otkucano zamenjuje izabrani tekst
|
||||
Update fields before printing Ažuriraj polja pre štampanja
|
||||
Use the Insert key to switch overtype on and off Taster Insert uključuje i isključuje prepisivanje
|
||||
|
|
@ -517,6 +519,7 @@
|
|||
Insert Above Umetni iznad
|
||||
Insert Below Umetni ispod
|
||||
Insert Caption Umetni natpis
|
||||
Insert Caption… Umetni natpis…
|
||||
Insert Citation Umetni citat
|
||||
Insert Endnote Umetni endnotu
|
||||
Insert Footnote Umetni fusnotu
|
||||
|
|
@ -912,6 +915,7 @@
|
|||
Table of Contents Sadržaj
|
||||
Table of\nContents Sadržaj
|
||||
Table Properties Svojstva tabele
|
||||
Table Properties… Svojstva tabele…
|
||||
Table Row Height Visina reda tabele
|
||||
Table style Stil tabele
|
||||
Table Style Options Opcije stila tabele
|
||||
|
|
@ -966,6 +970,10 @@
|
|||
Underline Words Only Podvuci samo reči
|
||||
Undo Opozovi
|
||||
Undo {action} Opozovi {action}
|
||||
Undo Actions Opozovi radnje
|
||||
Undo History Istorija opozivanja
|
||||
Undo 1 Action Opozovi 1 radnju
|
||||
Undo {n} Actions Opozovi radnje: {n}
|
||||
Ungroup Razgrupisanje
|
||||
Up Gore
|
||||
Update Field Ažuriraj polje
|
||||
|
|
@ -2067,5 +2075,13 @@
|
|||
Your system has animation effects turned on. U sistemu su efekti animacije uključeni.
|
||||
Your system has animation effects turned off. U sistemu su efekti animacije isključeni.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Sistem ne prijavljuje podešavanje animacije, pa efekti animacije ostaju uključeni.
|
||||
Select Column Izaberi kolonu
|
||||
Select Table Cells by Clicking Izbor ćelija tabele klikom
|
||||
Insert Row or Column by Clicking Umetanje reda ili kolone klikom
|
||||
Move Table by Dragging Premeštanje tabele prevlačenjem
|
||||
AutoFit Column by Double-Clicking Automatsko uklapanje kolone dvostrukim klikom
|
||||
Region: Region:
|
||||
Automatic ({region}) Automatski ({region})
|
||||
Start-up Pokretanje
|
||||
Show the Start screen when WordCraft starts Prikaži početni ekran pri pokretanju programa WordCraft
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Uključeno: WordCraft se otvara na Fajl › Početak, sa praznim dokumentom, šablonima i nedavnim dokumentima. Isključeno: otvara prazan dokument.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -208,6 +208,7 @@
|
|||
Preview Преглед
|
||||
Remove Password Уклони лозинку
|
||||
Save documents Чување докумената
|
||||
Search Претрага
|
||||
Shadow, glow and soft edges Сенка, сјај и меке ивице
|
||||
Shape Effects Ефекти облика
|
||||
Show all revisions inline Прикажи све измене у тексту
|
||||
|
|
@ -259,6 +260,7 @@
|
|||
Page Border Ивица стране
|
||||
Apply to: Примени на:
|
||||
These marks show even when Show/Hide ¶ is off. Ове ознаке се приказују и када је Прикажи/сакриј ¶ искључено.
|
||||
Title Bar Насловна трака
|
||||
Typing replaces selected text Откуцано замењује изабрани текст
|
||||
Update fields before printing Ажурирај поља пре штампања
|
||||
Use the Insert key to switch overtype on and off Тастер Insert укључује и искључује преписивање
|
||||
|
|
@ -521,6 +523,7 @@
|
|||
Insert Above Уметни изнад
|
||||
Insert Below Уметни испод
|
||||
Insert Caption Уметни натпис
|
||||
Insert Caption… Уметни натпис…
|
||||
Insert Citation Уметни цитат
|
||||
Insert Endnote Уметни ендноту
|
||||
Insert Footnote Уметни фусноту
|
||||
|
|
@ -916,6 +919,7 @@
|
|||
Table of Contents Садржај
|
||||
Table of\nContents Садржај
|
||||
Table Properties Својства табеле
|
||||
Table Properties… Својства табеле…
|
||||
Table Row Height Висина реда табеле
|
||||
Table style Стил табеле
|
||||
Table Style Options Опције стила табеле
|
||||
|
|
@ -970,6 +974,10 @@
|
|||
Underline Words Only Подвуци само речи
|
||||
Undo Опозови
|
||||
Undo {action} Опозови {action}
|
||||
Undo Actions Опозови радње
|
||||
Undo History Историја опозивања
|
||||
Undo 1 Action Опозови 1 радњу
|
||||
Undo {n} Actions Опозови радње: {n}
|
||||
Ungroup Разгруписање
|
||||
Up Горе
|
||||
Update Field Ажурирај поље
|
||||
|
|
@ -2071,5 +2079,13 @@
|
|||
Your system has animation effects turned on. У систему су ефекти анимације укључени.
|
||||
Your system has animation effects turned off. У систему су ефекти анимације искључени.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Систем не пријављује подешавање анимације, па ефекти анимације остају укључени.
|
||||
Select Column Изабери колону
|
||||
Select Table Cells by Clicking Избор ћелија табеле кликом
|
||||
Insert Row or Column by Clicking Уметање реда или колоне кликом
|
||||
Move Table by Dragging Премештање табеле превлачењем
|
||||
AutoFit Column by Double-Clicking Аутоматско уклапање колоне двоструким кликом
|
||||
Region: Регион:
|
||||
Automatic ({region}) Аутоматски ({region})
|
||||
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 се отвара на Фајл › Почетак, са празним документом, шаблонима и недавним документима. Искључено: отвара празан документ.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -644,7 +644,10 @@ fn estonian_covers_the_interface_catalog_and_keeps_count_labels_neutral() {
|
|||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let et = lang("et");
|
||||
assert_eq!(keys(et.0.source), keys(lang("es").0.source));
|
||||
// Estonian carries the interface catalog; Spanish and Turkish add the equation gallery and
|
||||
// built-in style names on top.
|
||||
assert!(keys(et.0.source).is_subset(&keys(lang("es").0.source)));
|
||||
assert!(keys(et.0.source).is_subset(&keys(lang("tr").0.source)));
|
||||
assert!(keys(lang("zh-hans").0.source).is_subset(&keys(et.0.source)));
|
||||
assert_eq!(tr(et, "Home"), "Avaleht");
|
||||
assert_eq!(tr(et, "Save"), "Salvesta");
|
||||
|
|
@ -919,9 +922,11 @@ fn polish_covers_every_key_of_every_other_catalog() {
|
|||
use std::collections::HashSet;
|
||||
let keys = |source| parse_entries(source).0.into_iter().map(|e| e.source).collect::<HashSet<_>>();
|
||||
let polish = keys(lang("pl").0.source);
|
||||
// Russian, Italian, Hungarian, Turkish and Bulgarian also translate the built-in style names
|
||||
// and the equation gallery, which the others don't yet; Polish matches every other catalog.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "ru" | "it" | "hu" | "tr" | "bg")) {
|
||||
// Italian and Hungarian still carry "Style: APA", which the interface no longer shows;
|
||||
// Hungarian and Bulgarian also name the citation styles, and Bulgarian has a few one-line
|
||||
// pieces of gallery labels. Polish matches every other catalog, including the style names and
|
||||
// equation gallery Russian translates.
|
||||
for other in Lang::all().filter(|l| *l != Lang::EN && !matches!(l.code(), "pl" | "it" | "hu" | "bg")) {
|
||||
let mut missing: Vec<String> = keys(other.0.source).difference(&polish).cloned().collect();
|
||||
missing.sort();
|
||||
assert!(missing.is_empty(), "pl lacks {}'s {missing:?}", other.code());
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
Insert Above Üste Ekle
|
||||
Insert Below Alta Ekle
|
||||
Insert Caption Resim Yazısı Ekle
|
||||
Insert Caption… Resim Yazısı Ekle…
|
||||
Insert Citation Atıf Ekle
|
||||
Insert Endnote Sonnot Ekle
|
||||
Insert Footnote Dipnot Ekle
|
||||
|
|
@ -621,6 +622,7 @@
|
|||
Save As Farklı Kaydet
|
||||
Save as Template Şablon Olarak Kaydet
|
||||
Saved Kaydedildi
|
||||
Search Ara
|
||||
Search Commands Komut Ara
|
||||
Search commands and help Komutlarda ve yardımda ara
|
||||
Search document Belgede ara
|
||||
|
|
@ -734,6 +736,7 @@
|
|||
Table of Figures Şekiller Tablosu
|
||||
Table of\nContents İçindekiler\nTablosu
|
||||
Table Properties Tablo Özellikleri
|
||||
Table Properties… Tablo Özellikleri…
|
||||
Table Row Height Tablo Satır Yüksekliği
|
||||
Table Style Options Tablo Stili Seçenekleri
|
||||
Table Styles Tablo Stilleri
|
||||
|
|
@ -767,6 +770,7 @@ preview Title Örnek
|
|||
TOA Heading Yetki Tablosu Başlığı
|
||||
TOC Heading İçindekiler Başlığı
|
||||
TOC {n} İçindekiler {n}
|
||||
Title Bar Başlık Çubuğu
|
||||
Top Border Üst Kenarlık
|
||||
Top of Page Sayfa Üstü
|
||||
Top: Üst:
|
||||
|
|
@ -784,6 +788,11 @@ preview Title Örnek
|
|||
Underline Words Only Yalnızca Sözcüklerin Altını Çiz
|
||||
Undo Geri Al
|
||||
Undo {action} Geri Al: {action}
|
||||
Undo Actions Eylemleri Geri Al
|
||||
Undo History Geri Alma Geçmişi
|
||||
Undo 1 Action 1 Eylemi Geri Al
|
||||
Undo {n} Actions {n} Eylemi Geri Al
|
||||
Unformatted Text Biçimlendirilmemiş Metin
|
||||
Up Yukarı
|
||||
Update Field Alanı Güncelle
|
||||
Update Index Dizini Güncelle
|
||||
|
|
@ -966,7 +975,6 @@ preview Title Örnek
|
|||
Formatted Text Biçimlendirilmiş Metin
|
||||
Rich Text Format (RTF) Zengin Metin Biçimi (RTF)
|
||||
HTML Format HTML Biçimi
|
||||
Unformatted Text Biçimlendirilmemiş Metin
|
||||
Result Sonuç
|
||||
Drops all formatting and pastes plain text. Tüm biçimlendirmeyi kaldırır ve düz metin yapıştırır.
|
||||
Advanced Find Gelişmiş Bul
|
||||
|
|
@ -2448,5 +2456,20 @@ preview Title Örnek
|
|||
Your system has animation effects turned on. Sisteminizde animasyon efektleri açık.
|
||||
Your system has animation effects turned off. Sisteminizde animasyon efektleri kapalı.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Sisteminiz bir animasyon ayarı bildirmiyor, bu nedenle animasyon efektleri açık kalır.
|
||||
Select Column Tablo Sütunu Seç
|
||||
Select Table Cells by Clicking Tablo Hücrelerini Tıklayarak Seç
|
||||
Insert Row or Column by Clicking Tıklayarak Satır veya Sütun Ekle
|
||||
Move Table by Dragging Tabloyu Sürükleyerek Taşı
|
||||
AutoFit Column by Double-Clicking Sütunu Çift Tıklayarak Otomatik Sığdır
|
||||
Region: Bölge:
|
||||
Automatic ({region}) Otomatik ({region})
|
||||
Start-up Başlangıç
|
||||
Show the Start screen when WordCraft starts WordCraft açılırken Başlangıç ekranını göster
|
||||
On: WordCraft opens on File › Home, with a blank document, the templates and your recent documents. Off: it opens a blank document. Açık: WordCraft, Dosya › Giriş sayfasında boş belge, şablonlar ve son belgelerinizle açılır. Kapalı: boş bir belge açar.
|
||||
Typing Yazma
|
||||
Level 4 Düzey 4
|
||||
Level 5 Düzey 5
|
||||
Level 6 Düzey 6
|
||||
Level 7 Düzey 7
|
||||
Level 8 Düzey 8
|
||||
Level 9 Düzey 9
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 15.
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue