mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 09:20:15 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5e0458af |
Generated
+25
-55
@@ -1103,7 +1103,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-proof"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1520,19 +1520,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1600,26 +1587,6 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -6785,7 +6752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6820,7 +6787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6833,11 +6800,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6851,7 +6818,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6868,7 +6835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6892,7 +6859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6925,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6939,7 +6906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6958,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6966,7 +6933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6986,7 +6953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6998,7 +6965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7006,7 +6973,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7027,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7041,9 +7008,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
@@ -7051,7 +7017,6 @@ dependencies = [
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -7074,7 +7039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7093,7 +7058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7256,7 +7221,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7615,6 +7580,11 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+4
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
|
||||
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
|
||||
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
# WordCraft
|
||||
|
||||
A clean-room, Rust-native word processor. These are the words its interface, docs and code use for
|
||||
the same things.
|
||||
|
||||
## Languages and proofing
|
||||
|
||||
**Interface language**:
|
||||
The language of WordCraft's own menus, buttons and messages.
|
||||
_Avoid_: Display language, UI locale
|
||||
|
||||
**Proofing language**:
|
||||
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
|
||||
are checked. A language is told apart from another by its tag (language, script, region, variant),
|
||||
never by the name a list shows for it.
|
||||
_Avoid_: Text language, editing language, document language
|
||||
|
||||
**Language pack**:
|
||||
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
|
||||
_Avoid_: Dictionary download, language data (that is the embedded data)
|
||||
|
||||
**Language list**:
|
||||
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
|
||||
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
|
||||
every available) Language pack, each once.
|
||||
_Avoid_: Language menu (that is only one of the places it is shown)
|
||||
|
||||
**Your languages**:
|
||||
The proofing languages Options › Language lists: each English variant in use (the default proofing
|
||||
language and any in the open document), every language with an installed
|
||||
Language pack, and every proofing language used in the open document.
|
||||
_Avoid_: Authoring languages, preferred languages
|
||||
|
||||
**Default proofing language**:
|
||||
The proofing language new blank documents get, set with Set as default; without one, the interface
|
||||
language.
|
||||
_Avoid_: Preferred language
|
||||
|
||||
**Proofing status**:
|
||||
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
|
||||
a system dictionary, available to install, or not available.
|
||||
|
||||
**Custom dictionary**:
|
||||
The words you added with Add to Dictionary, accepted in every language.
|
||||
_Avoid_: User dictionary (in the interface)
|
||||
|
||||
**Dictionaries folder**:
|
||||
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
|
||||
comes after it).
|
||||
_Avoid_: Custom dictionaries
|
||||
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
|
||||
+4
-36
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
059cbf95c2a3eb95d637ca8584faca1129d17f48
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
|
||||
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
@@ -62,8 +62,8 @@ impl Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: Arc::new(StyleSheet::builtin()),
|
||||
numbering: Arc::default(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
|
||||
@@ -228,11 +228,6 @@ fn block_cost(b: &Block) -> (usize, bool) {
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
// The formatting a clone copies too: style ids, font names and the like in the runs,
|
||||
// the mark and the paragraph's properties.
|
||||
let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props)));
|
||||
let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len));
|
||||
cost = cost.saturating_add(runs).saturating_add(props);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
@@ -1054,7 +1049,7 @@ impl Document {
|
||||
let mut default_style = None;
|
||||
if keep_styles {
|
||||
if src.styles.get("Normal").is_none() {
|
||||
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
}
|
||||
// Ids given out here are taken too: two styles never get the same new one.
|
||||
let mut given = std::collections::HashSet::new();
|
||||
@@ -1089,7 +1084,7 @@ impl Document {
|
||||
abs_map.insert(a.id, id);
|
||||
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
|
||||
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
|
||||
Arc::make_mut(&mut self.numbering).abstracts.push(a);
|
||||
self.numbering.abstracts.push(a);
|
||||
}
|
||||
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut nums = BTreeMap::new();
|
||||
@@ -1099,7 +1094,7 @@ impl Document {
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
|
||||
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
|
||||
Arc::make_mut(&mut self.numbering).nums.push(n);
|
||||
self.numbering.nums.push(n);
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
@@ -1138,7 +1133,7 @@ impl Document {
|
||||
}
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
renumber(&mut st.para.numbering);
|
||||
Arc::make_mut(&mut self.styles).upsert(st);
|
||||
self.styles.upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
|
||||
@@ -415,24 +415,6 @@ fn pasted_box_copies_are_capped_in_bytes() {
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
@@ -663,15 +645,13 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// The deepest nesting allowed, each table in the first cell of the one around it, ahead of
|
||||
// that cell's paragraph: the innermost paragraphs come first in the document but the
|
||||
// paragraph walk stops short.
|
||||
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 1..MAX_TABLE_DEPTH {
|
||||
for _ in 0..20 {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
@@ -692,19 +672,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(style);
|
||||
theirs.styles.upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
@@ -748,11 +728,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = Arc::new(StyleSheet::empty());
|
||||
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
@@ -766,15 +746,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
@@ -814,10 +794,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
@@ -845,8 +825,8 @@ fn adopted_styles_never_become_our_defaults() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
|
||||
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
|
||||
//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions
|
||||
//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the
|
||||
//! renderer) and edits copy only what they touch (`Arc::make_mut`).
|
||||
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
|
||||
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
|
||||
//! touch (`Arc::make_mut`).
|
||||
//!
|
||||
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
|
||||
//! compare in document order.
|
||||
@@ -98,12 +98,8 @@ pub struct Document {
|
||||
/// Properties of the last section (OOXML's body-level `w:sectPr`).
|
||||
pub last_section: SectionProps,
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
/// Shared, like the blocks: an undo step copies it only when a command changes it (a full
|
||||
/// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes
|
||||
/// a step).
|
||||
pub styles: Arc<StyleSheet>,
|
||||
/// Shared like [`Document::styles`].
|
||||
pub numbering: Arc<Numbering>,
|
||||
pub styles: StyleSheet,
|
||||
pub numbering: Numbering,
|
||||
pub comments: BTreeMap<u32, Comment>,
|
||||
pub revisions: Vec<Revision>,
|
||||
pub settings: Settings,
|
||||
|
||||
+2
-114
@@ -1,6 +1,5 @@
|
||||
//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the
|
||||
//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser
|
||||
//! would run them.
|
||||
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
|
||||
//! exported HTML/Markdown file where a browser would run them.
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
|
||||
@@ -40,121 +39,10 @@ fn scheme(url: &str) -> Option<String> {
|
||||
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`)
|
||||
/// or `#bookmark`. None for any other field, or one with no target.
|
||||
pub fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter().peekable();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\');
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some(arg @ (a, _)) = args.next() {
|
||||
if !is_switch(&arg) {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none.
|
||||
let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@");
|
||||
let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l")
|
||||
&& let Some((v, _)) = value
|
||||
{
|
||||
anchor = Some(v.as_str());
|
||||
}
|
||||
}
|
||||
let url = url.map(str::trim).filter(|u| !u.is_empty());
|
||||
let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty());
|
||||
match (url, anchor) {
|
||||
// `\l` replaces a fragment the address already has, as in Word.
|
||||
(Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes a backslash escapes the next character, as in Word.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling.
|
||||
#[test]
|
||||
fn hyperlink_field_codes_name_their_targets() {
|
||||
for (code, want) in [
|
||||
(r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")),
|
||||
(r#"HYPERLINK \l "heading""#, Some("#heading")),
|
||||
(r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")),
|
||||
(r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")),
|
||||
("HYPERLINK https://example.org/b", Some("https://example.org/b")),
|
||||
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
|
||||
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
|
||||
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
|
||||
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
|
||||
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
|
||||
// Switches that take a value, before or after the address, and those that take none.
|
||||
(r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")),
|
||||
(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")),
|
||||
(r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")),
|
||||
(r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")),
|
||||
(r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")),
|
||||
(r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "bm" \l"#, Some("#bm")),
|
||||
// A bookmark's own `#`, an address that has a fragment, and an empty address.
|
||||
(r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")),
|
||||
(r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")),
|
||||
(r#"HYPERLINK "" \l "x""#, Some("#x")),
|
||||
// No target, or not a `HYPERLINK` field.
|
||||
("HYPERLINK", None),
|
||||
(r#"HYPERLINK "" \o "tip""#, None),
|
||||
(r#"HYPERLINKS "https://example.org/""#, None),
|
||||
(r#"HYPERLINKX "http://example.com""#, None),
|
||||
(r#"HYPERLINK\l "x""#, None),
|
||||
("PAGEREF _Toc1 \\h", None),
|
||||
("ab日本語", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_and_mail_links_are_followable() {
|
||||
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
|
||||
|
||||
@@ -71,12 +71,10 @@ pub struct Table {
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
|
||||
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
|
||||
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
|
||||
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
|
||||
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
|
||||
pub const MAX_TABLE_DEPTH: usize = 18;
|
||||
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
|
||||
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
|
||||
/// when it is saved, copied or dropped (#1596).
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
|
||||
+11
-12
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
@@ -36,8 +35,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
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 });
|
||||
@@ -61,9 +60,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
let mut d = Document::new();
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
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());
|
||||
@@ -82,9 +81,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
@@ -188,7 +187,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
|
||||
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
|
||||
|
||||
let n = Arc::make_mut(&mut d.numbering);
|
||||
let n = &mut d.numbering;
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
@@ -230,11 +229,11 @@ fn same_content_sees_every_change() {
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
@@ -453,9 +452,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
fn adopting_relinks_lists_in_deeply_nested_tables() {
|
||||
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
|
||||
let mut ours = Document::from_text("ours");
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let mut p = Paragraph::with_text("item", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
|
||||
|
||||
@@ -109,13 +109,12 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
ICO.get(i as usize).copied()
|
||||
}
|
||||
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in
|
||||
/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap
|
||||
/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them.
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
|
||||
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
|
||||
/// reader maps them.
|
||||
fn brc_style(t: u8) -> BorderStyle {
|
||||
match t {
|
||||
0x00 => BorderStyle::None,
|
||||
0x02 => BorderStyle::Thick,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x06 => BorderStyle::Dotted,
|
||||
0x07 | 0x16 => BorderStyle::Dashed,
|
||||
@@ -126,15 +125,15 @@ fn brc_style(t: u8) -> BorderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz`
|
||||
/// reads (1-point steps for page borders, which never reach a paragraph property).
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
|
||||
/// 1-point steps for page borders, which never reach a paragraph property).
|
||||
fn brc_width(t: u8, dpt: u8) -> f32 {
|
||||
if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) }
|
||||
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
|
||||
}
|
||||
|
||||
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
|
||||
/// truncated operand is ignored.
|
||||
pub(crate) fn brc80(op: &[u8]) -> Option<Border> {
|
||||
fn brc80(op: &[u8]) -> Option<Border> {
|
||||
let [dpt, ty, ico_index, tail, ..] = op else { return None };
|
||||
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
|
||||
return Some(Border::default());
|
||||
|
||||
+87
-12
@@ -25,9 +25,7 @@ mod stsh;
|
||||
mod table;
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::link::hyperlink_target;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
@@ -250,9 +248,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts));
|
||||
doc.numbering = Arc::new(list::parse(&table, &fib, &fonts));
|
||||
let sheet: StyleSheet = StyleSheet::clone(&doc.styles);
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
let sheet: StyleSheet = doc.styles.clone();
|
||||
let sects = sections::parse(&word, &table, &fib);
|
||||
let stories = sections::header_stories(&table, &fib);
|
||||
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
|
||||
@@ -366,11 +364,6 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
// 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.
|
||||
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
|
||||
// The first paragraph ending at each CP, found once rather than by a scan per section.
|
||||
let mut ending: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
|
||||
for (i, p) in paras.iter().enumerate() {
|
||||
ending.entry(p.end_cp).or_insert(i);
|
||||
}
|
||||
let last = sects.last().map(|s| s.props.clone());
|
||||
for (si, sec) in sects.iter().enumerate() {
|
||||
let mut props = sec.props.clone();
|
||||
@@ -381,8 +374,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
if is_final {
|
||||
continue; // handled below through `last`
|
||||
}
|
||||
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
|
||||
p.para.section = Some(Box::new(props));
|
||||
for p in paras.iter_mut() {
|
||||
if p.end_cp == sec.end_cp {
|
||||
p.para.section = Some(Box::new(props));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut l) = last {
|
||||
@@ -724,6 +720,85 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
/// (`#bookmark` for internal links); `None` when not a hyperlink.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
let t = instr.trim();
|
||||
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
|
||||
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url: Option<String> = None;
|
||||
let mut local: Option<String> = None;
|
||||
let mut switch: Option<&str> = None;
|
||||
|
||||
let mut i = 0;
|
||||
let bytes = rest.as_bytes();
|
||||
while i < bytes.len() {
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[i] == b'\\' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
|
||||
i += 1;
|
||||
}
|
||||
switch = Some(rest.get(start..i).unwrap_or(""));
|
||||
continue;
|
||||
}
|
||||
let arg = if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'"' {
|
||||
i += 1;
|
||||
}
|
||||
let val = rest.get(start..i).unwrap_or("");
|
||||
if i < bytes.len() && bytes[i] == b'"' {
|
||||
i += 1;
|
||||
}
|
||||
val.to_string()
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
rest.get(start..i).unwrap_or("").to_string()
|
||||
};
|
||||
let clean_arg = arg.replace("%22", "\"");
|
||||
match switch.take() {
|
||||
Some("\\l") => local = Some(clean_arg),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if url.is_none() {
|
||||
url = Some(clean_arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (url, local) {
|
||||
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
|
||||
}
|
||||
(Some(u), _) if !u.is_empty() => Some(u),
|
||||
(None, Some(l)) if !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
Some(format!("#{frag}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
|
||||
+31
-29
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, para_block};
|
||||
|
||||
@@ -101,12 +101,6 @@ impl RowInfo {
|
||||
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// How many per-cell overrides the row keeps, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn overrides(&self) -> usize {
|
||||
self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len()
|
||||
}
|
||||
|
||||
/// The width percentage cell `i` ends up with, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
|
||||
@@ -114,15 +108,6 @@ impl RowInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often
|
||||
/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark.
|
||||
fn set<T>(slots: &mut Vec<(u8, T)>, c: u8, v: T) {
|
||||
match slots.iter_mut().find(|(k, _)| *k == c) {
|
||||
Some(slot) => slot.1 = v,
|
||||
None => slots.push((c, v)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let mut r = RowInfo::default();
|
||||
let mut gap_half: Option<i16> = None;
|
||||
@@ -159,10 +144,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
T_DXA_GAP_HALF => gap_half = s16(&prl),
|
||||
T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand),
|
||||
T_MERGE => {
|
||||
// Rows apply only the first MAX_COLS merges.
|
||||
if let [a, b, ..] = prl.operand
|
||||
&& b > a
|
||||
&& r.merges.len() < MAX_COLS
|
||||
{
|
||||
r.merges.push((*a, *b));
|
||||
}
|
||||
@@ -175,8 +158,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
1 => VMerge::Continue,
|
||||
_ => VMerge::None,
|
||||
};
|
||||
// The first override for a cell wins.
|
||||
if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) {
|
||||
if m != VMerge::None {
|
||||
r.vmerge.push((*itc, m));
|
||||
}
|
||||
}
|
||||
@@ -186,7 +168,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
|
||||
{
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.shading, c, Some(rgb));
|
||||
r.shading.push((c, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +176,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
let cells = prl.operand.get(1..).unwrap_or(&[]);
|
||||
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
|
||||
if let Some(rgb) = shd80_rgb(b) {
|
||||
set(&mut r.shading, i as u8, Some(rgb));
|
||||
r.shading.push((i as u8, Some(rgb)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +196,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
|
||||
continue;
|
||||
}
|
||||
set(&mut r.shading, (first + i) as u8, shd_rgb(b));
|
||||
r.shading.push(((first + i) as u8, shd_rgb(b)));
|
||||
}
|
||||
}
|
||||
T_CELL_WIDTH => {
|
||||
@@ -225,12 +207,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
if *fts == 3 {
|
||||
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.widths, c, w);
|
||||
r.widths.push((c, w));
|
||||
}
|
||||
} else if *fts == 2 {
|
||||
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.width_pcts, c, pct);
|
||||
r.width_pcts.push((c, pct));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,8 +224,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
|
||||
2 => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
for c in *a..(*b).min(MAX_COLS as u8) {
|
||||
set(&mut r.valign, c, v);
|
||||
for c in *a..*b {
|
||||
r.valign.push((c, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,12 +440,32 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
|
||||
/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are.
|
||||
/// left, bottom, right, inside-horizontal, inside-vertical.
|
||||
fn table_borders(operand: &[u8]) -> Option<Borders> {
|
||||
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80);
|
||||
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80);
|
||||
Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) })
|
||||
}
|
||||
|
||||
/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte.
|
||||
fn brc80(b: &[u8]) -> Option<Border> {
|
||||
let [w, ty, ico, flags] = b else { return None };
|
||||
let style = match ty {
|
||||
0x00 => BorderStyle::None,
|
||||
0x02 | 0x15 => BorderStyle::Thick,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x04 | 0x05 => BorderStyle::Dotted,
|
||||
0x06 | 0x07 => BorderStyle::Dashed,
|
||||
0x08 | 0x09 => BorderStyle::DotDash,
|
||||
0x0A | 0x18 => BorderStyle::Wave,
|
||||
0x11 => BorderStyle::Triple,
|
||||
_ => BorderStyle::Single,
|
||||
};
|
||||
if *ty == 0 && *w == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 })
|
||||
}
|
||||
|
||||
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
|
||||
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
|
||||
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
|
||||
|
||||
+7
-114
@@ -1018,32 +1018,6 @@ fn two_sections_box_the_first() {
|
||||
assert!(!doc.last_section.title_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_ending_off_a_paragraph_mark_attach_nowhere() {
|
||||
// Hostile section ends that miss every paragraph mark are skipped; the one that ends a
|
||||
// paragraph still boxes it. Paragraphs are found by their end, not scanned per section.
|
||||
let mut sections: Vec<(u32, Vec<u8>)> = (1..6).map(|cp| (cp, Vec::new())).collect();
|
||||
sections.push((6, grpprl(&[(0x300A, &[0x01])])));
|
||||
sections.push((12, Vec::new()));
|
||||
let spec = StructSpec {
|
||||
text: "first\rsecond\r",
|
||||
header_stories: vec![],
|
||||
sections,
|
||||
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");
|
||||
let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page);
|
||||
assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_story_becomes_part() {
|
||||
let spec = StructSpec {
|
||||
@@ -1209,53 +1183,6 @@ fn table_merges_borders_shading() {
|
||||
assert_eq!(top.width, 0.5);
|
||||
}
|
||||
|
||||
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
|
||||
#[test]
|
||||
fn table_borders_read_border_types_as_paragraphs_do() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let text = "a\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let row_end = {
|
||||
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
|
||||
v.extend_from_slice(&0xD605u16.to_le_bytes());
|
||||
v.push(0x18);
|
||||
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
|
||||
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
|
||||
v.extend_from_slice(&brc);
|
||||
}
|
||||
v
|
||||
};
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
|
||||
let b = tbl.props.borders.expect("table borders");
|
||||
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
|
||||
let want = [
|
||||
(S::Dotted, true, 1.0),
|
||||
(S::Triple, true, 1.0),
|
||||
(S::Wave, true, 1.0),
|
||||
(S::None, false, 0.0),
|
||||
(S::Single, true, 12.0),
|
||||
(S::None, false, 0.25),
|
||||
];
|
||||
assert_eq!(seen, want.map(Some));
|
||||
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
|
||||
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
|
||||
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// D3: lists (PlfLst + PlfLfo) and the combined document.
|
||||
|
||||
@@ -1516,16 +1443,6 @@ fn hyperlink_target_needs_the_whole_field_name() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
|
||||
}
|
||||
|
||||
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
|
||||
#[test]
|
||||
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_become_objects() {
|
||||
let text = "Hello world\r";
|
||||
@@ -2458,23 +2375,6 @@ fn second_table_gets_its_own_grid() {
|
||||
assert_eq!(tables[1].props.indent, None, "indent reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cell_sprms_keep_one_override_per_cell() {
|
||||
// A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one
|
||||
// slot per cell, the last value winning, instead of a copy per repetition.
|
||||
let mut sprms = Vec::new();
|
||||
for w in 0..200u16 {
|
||||
let [w0, w1] = (w * 20).to_le_bytes();
|
||||
sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1]));
|
||||
sprms.push(sprm_cb(0xD62C, &[0, 255, 1]));
|
||||
sprms.push([0x24, 0x56, 0, 2].to_vec());
|
||||
}
|
||||
let row = crate::table::decode(&sprms.concat());
|
||||
assert_eq!(row.width_of(0), Some(199.0), "the last width wins");
|
||||
assert_eq!(row.width_of(62), Some(199.0));
|
||||
assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprm_t_cell_width_twips_and_percentage() {
|
||||
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
|
||||
@@ -2539,21 +2439,17 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
|
||||
}
|
||||
|
||||
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
|
||||
/// border (type 0x02) stays thick in both, as tables read it too.
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
|
||||
#[test]
|
||||
fn paragraph_borders_read_from_both_forms() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
|
||||
let thick80 = [8u8, 0x02, 0x06, 0x01];
|
||||
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
|
||||
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
|
||||
let text = "boxed\r\r";
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
papx_entries: vec![
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc)])),
|
||||
],
|
||||
..structured_spec(text, text.chars().count() as u32)
|
||||
};
|
||||
@@ -2561,16 +2457,13 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
|
||||
let b = p0.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
|
||||
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
|
||||
let b = p1.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
|
||||
let top = p1.props.borders.expect("borders").top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
|
||||
@@ -172,9 +172,8 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
|
||||
/// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL
|
||||
/// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b,
|
||||
/// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
|
||||
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<Rgb> {
|
||||
let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i);
|
||||
let slot = match e.attr(attr)? {
|
||||
@@ -200,7 +199,8 @@ impl PropCtx {
|
||||
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
|
||||
if let Some(t) = byte(tint) {
|
||||
c = adjust_lum(c, |l| l * t + (1.0 - t));
|
||||
} else if let Some(s) = byte(shade) {
|
||||
}
|
||||
if let Some(s) = byte(shade) {
|
||||
c = adjust_lum(c, |l| l * s);
|
||||
}
|
||||
Some(c)
|
||||
@@ -208,10 +208,6 @@ impl PropCtx {
|
||||
|
||||
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
|
||||
pub fn shd_fill(&self, e: &El) -> Option<Rgb> {
|
||||
// `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78).
|
||||
if e.attr("w:val") == Some("nil") {
|
||||
return None;
|
||||
}
|
||||
if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") {
|
||||
return Some(c);
|
||||
}
|
||||
@@ -286,7 +282,7 @@ impl PropCtx {
|
||||
c.underline_color =
|
||||
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
|
||||
}
|
||||
"w:bdr" => c.border = Some(self.border(k)),
|
||||
"w:bdr" => c.border = Some(border(k)),
|
||||
"w:shd" => c.shading = self.shd_fill(k),
|
||||
"w:vertAlign" => {
|
||||
c.vert_align = k.attr("w:val").map(|v| match v {
|
||||
@@ -365,7 +361,7 @@ impl PropCtx {
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
"w:pBdr" => p.borders = Some(self.borders(k)),
|
||||
"w:pBdr" => p.borders = Some(borders(k)),
|
||||
"w:shd" => p.shading = self.shd_fill(k),
|
||||
"w:tabs" => {
|
||||
let tabs: Vec<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
|
||||
@@ -480,32 +476,29 @@ fn shd_fill(e: &El) -> Option<Rgb> {
|
||||
None
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
/// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text.
|
||||
pub fn border(&self, e: &El) -> Border {
|
||||
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
|
||||
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
|
||||
let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse));
|
||||
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
|
||||
Border { style, width, color, space }
|
||||
}
|
||||
pub fn border(e: &El) -> Border {
|
||||
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
|
||||
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
|
||||
let color = e.attr("w:color").and_then(Rgb::parse);
|
||||
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
|
||||
Border { style, width, color, space }
|
||||
}
|
||||
|
||||
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
|
||||
pub fn borders(&self, e: &El) -> Borders {
|
||||
let mut b = Borders::default();
|
||||
for k in e.els() {
|
||||
match k.local() {
|
||||
"top" => b.top = Some(self.border(k)),
|
||||
"left" | "start" => b.left = Some(self.border(k)),
|
||||
"bottom" => b.bottom = Some(self.border(k)),
|
||||
"right" | "end" => b.right = Some(self.border(k)),
|
||||
"between" | "insideH" => b.between = Some(self.border(k)),
|
||||
"insideV" => b.inside_v = Some(self.border(k)),
|
||||
_ => {}
|
||||
}
|
||||
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
|
||||
pub fn borders(e: &El) -> Borders {
|
||||
let mut b = Borders::default();
|
||||
for k in e.els() {
|
||||
match k.local() {
|
||||
"top" => b.top = Some(border(k)),
|
||||
"left" | "start" => b.left = Some(border(k)),
|
||||
"bottom" => b.bottom = Some(border(k)),
|
||||
"right" | "end" => b.right = Some(border(k)),
|
||||
"between" | "insideH" => b.between = Some(border(k)),
|
||||
"insideV" => b.inside_v = Some(border(k)),
|
||||
_ => {}
|
||||
}
|
||||
b
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
|
||||
@@ -539,7 +532,7 @@ impl PropCtx {
|
||||
}
|
||||
"w:jc" => t.align = k.attr("w:val").map(align),
|
||||
"w:tblInd" => t.indent = tw(k, "w:w"),
|
||||
"w:tblBorders" => t.borders = Some(self.borders(k)),
|
||||
"w:tblBorders" => t.borders = Some(borders(k)),
|
||||
"w:shd" => t.shading = self.shd_fill(k),
|
||||
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
|
||||
"w:bidiVisual" => t.bidi_visual = on_off(k),
|
||||
@@ -645,7 +638,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
"w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63),
|
||||
"w:hMerge" => hcont = k.attr("w:val") != Some("restart"),
|
||||
"w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue },
|
||||
"w:tcBorders" => c.borders = Some(pc.borders(k)),
|
||||
"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),
|
||||
@@ -682,7 +675,7 @@ pub struct HfRef {
|
||||
}
|
||||
|
||||
/// `w:sectPr` (header/footer references are returned for the caller to resolve).
|
||||
pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
let mut s = SectionProps::default();
|
||||
let mut refs = Vec::new();
|
||||
for k in e.els() {
|
||||
@@ -787,7 +780,7 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
"w:pgBorders" => s.page_borders = Some(pc.borders(k)),
|
||||
"w:pgBorders" => s.page_borders = Some(borders(k)),
|
||||
"w:docGrid" => {
|
||||
let mut g = DocGrid::default();
|
||||
if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) {
|
||||
|
||||
@@ -289,10 +289,10 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
|
||||
let (mut sp, refs) = sectpr(&self.pc, s);
|
||||
let (mut sp, refs) = sectpr(s);
|
||||
// §17.13.5.32: the section's properties before a tracked change (no header references).
|
||||
if let Some(ch) = s.child("w:sectPrChange") {
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default();
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
|
||||
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
for r in refs {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
|
||||
use wordcraft_doc::link::hyperlink_target;
|
||||
|
||||
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
|
||||
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
|
||||
@@ -25,6 +24,69 @@ fn is_range_instr(instr: &str) -> bool {
|
||||
crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
|
||||
/// `#bookmark`. None for any other field, or one with no target.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some((a, quoted)) = args.next() {
|
||||
if *quoted || !a.starts_with('\\') {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
|
||||
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l") {
|
||||
anchor = value.map(|(v, _)| v.as_str());
|
||||
}
|
||||
}
|
||||
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
|
||||
match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
// ---- fields ----
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
|
||||
@@ -187,7 +186,7 @@ impl Reader<'_> {
|
||||
if !sheet.styles.iter().any(|s| s.quick) {
|
||||
sheet.add_default_gallery();
|
||||
}
|
||||
self.doc.styles = Arc::new(sheet);
|
||||
self.doc.styles = sheet;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -197,7 +196,7 @@ impl Reader<'_> {
|
||||
let mut t = TableStyleParts::default();
|
||||
if let Some(tp) = s.child("w:tblPr") {
|
||||
if let Some(b) = tp.child("w:tblBorders") {
|
||||
t.borders = Some(self.pc.borders(b));
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
@@ -211,7 +210,7 @@ impl Reader<'_> {
|
||||
let f = PartFormat {
|
||||
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
|
||||
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
|
||||
align: ppr.as_ref().and_then(|p| p.align),
|
||||
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
|
||||
"center" => VAlign::Center,
|
||||
@@ -230,7 +229,7 @@ impl Reader<'_> {
|
||||
t.fill = f.fill;
|
||||
}
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(self.pc.borders(b));
|
||||
t.borders = Some(props::borders(b));
|
||||
}
|
||||
if f.v_align.is_some() {
|
||||
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
|
||||
@@ -299,8 +298,8 @@ impl Reader<'_> {
|
||||
a.levels = levels;
|
||||
}
|
||||
}
|
||||
Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts;
|
||||
Arc::make_mut(&mut self.doc.numbering).nums = nums;
|
||||
self.doc.numbering.abstracts = abstracts;
|
||||
self.doc.numbering.nums = nums;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! in a right-to-left paragraph.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
@@ -190,6 +189,6 @@ fn a_language_the_run_already_has_is_kept() {
|
||||
#[test]
|
||||
fn an_inherited_bidi_language_is_not_repeated() {
|
||||
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into());
|
||||
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
|
||||
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
|
||||
//! the reader reads an omitted size as 10 pt.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
|
||||
fn resolved(d: &Document) -> f32 {
|
||||
@@ -20,7 +19,7 @@ fn defaults(xml: &str) -> &str {
|
||||
#[test]
|
||||
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
d.styles.default_chr.size = None;
|
||||
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
|
||||
@@ -34,7 +33,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
fn an_explicit_default_size_is_written_as_it_is() {
|
||||
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = Some(size);
|
||||
d.styles.default_chr.size = Some(size);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
|
||||
}
|
||||
@@ -60,8 +59,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() {
|
||||
// The written default is the resolver's own fallback, not what the Normal style resolves to,
|
||||
// so a style that sets a size keeps it and one that does not still falls back to the default.
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") {
|
||||
d.styles.default_chr.size = None;
|
||||
if let Some(normal) = d.styles.get_mut("Normal") {
|
||||
normal.chr.size = Some(14.0);
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
@@ -253,7 +252,7 @@ fn omitted_paragraph_defaults_can_be_edited() {
|
||||
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
|
||||
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
assert!(!d.styles.default_para_present);
|
||||
Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0);
|
||||
d.styles.default_para.space_after = Some(12.0);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert!(again.styles.default_para_present);
|
||||
assert_eq!(again.styles.default_para.space_after, Some(12.0));
|
||||
@@ -2170,68 +2169,6 @@ fn theme_colour_wins_over_the_fixed_colour() {
|
||||
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
|
||||
}
|
||||
|
||||
/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name.
|
||||
#[test]
|
||||
fn nil_shading_paints_no_fill() {
|
||||
let body = r#"
|
||||
<w:tbl><w:tblGrid><w:gridCol w:w="200"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr>
|
||||
<w:tc><w:tcPr><w:shd w:val="nil" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc>
|
||||
<w:tc><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:pPr><w:shd w:val="nil" w:fill="C00000" w:themeFill="accent2"/></w:pPr><w:r><w:rPr><w:shd w:val="nil" w:color="C00000" w:fill="C00000"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||
assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0)));
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].props.shading, None);
|
||||
assert_eq!(p[0].props_of_char(0).shading, None);
|
||||
}
|
||||
|
||||
/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black.
|
||||
#[test]
|
||||
fn border_theme_colour_wins_over_the_fixed_colour() {
|
||||
let body = r#"
|
||||
<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="background1"/><w:left w:val="single" w:sz="4" w:color="FF0000"/></w:pBdr></w:pPr><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:color="auto" w:themeColor="accent2"/></w:rPr><w:t>x</w:t></w:r></w:p>
|
||||
<w:tbl><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="accent1"/></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders><w:left w:val="single" w:sz="4" w:color="000000" w:themeColor="accent6" w:themeShade="BF"/></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p/>
|
||||
<w:sectPr><w:pgBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="text2"/></w:pgBorders></w:sectPr>"#;
|
||||
let styles = r#"<w:style w:type="table" w:styleId="T"><w:name w:val="T"/><w:tblPr><w:tblBorders><w:bottom w:val="single" w:sz="4" w:color="000000" w:themeColor="accent3"/></w:tblBorders></w:tblPr></w:style>"#;
|
||||
let d = themed_docx(body, styles, None);
|
||||
let p = paras(&d);
|
||||
let pb = p[0].props.borders.unwrap();
|
||||
assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF)));
|
||||
assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one");
|
||||
assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31)));
|
||||
let Block::Table(t) = &*d.body[1] else { panic!("table second") };
|
||||
assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4)));
|
||||
let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color);
|
||||
assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}");
|
||||
assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A)));
|
||||
let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders);
|
||||
assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5)));
|
||||
}
|
||||
|
||||
/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours,
|
||||
/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a).
|
||||
#[test]
|
||||
fn theme_tint_wins_over_theme_shade() {
|
||||
let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#;
|
||||
let body = format!(
|
||||
r#"<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" {both}/></w:pBdr></w:pPr><w:r><w:rPr><w:color w:val="000000" {both}/><w:bdr w:val="single" w:sz="4" w:color="000000" {both}/><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent1" w:themeFillTint="99" w:themeFillShade="7F"/></w:rPr><w:t>a</w:t></w:r></w:p>"#
|
||||
);
|
||||
let d = themed_docx(&body, "", None);
|
||||
let p = paras(&d);
|
||||
let ch = p[0].props_of_char(0);
|
||||
// accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val.
|
||||
let tinted = Rgb(0x8E, 0xAA, 0xDB);
|
||||
assert!(near(ch.color, tinted), "text colour {:?}", ch.color);
|
||||
let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(run_border, tinted), "text border {run_border:?}");
|
||||
let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(para_border, tinted), "paragraph border {para_border:?}");
|
||||
assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_colours_follow_the_colour_scheme_mapping() {
|
||||
// Styles are read before settings; the mapping still applies to them.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
|
||||
|
||||
@@ -192,7 +191,7 @@ fn glossary_entries_are_read_into_the_model() {
|
||||
fn edited_glossary_is_written_from_the_model() {
|
||||
let mut doc = wordcraft_docx::read(&with_glossary()).unwrap();
|
||||
doc.glossary[0].name = "Sign-off".into();
|
||||
let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let mut listed = Paragraph::with_text("Second line", CharProps::default());
|
||||
listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
doc.glossary.push(BuildingBlock {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
@@ -273,7 +272,7 @@ fn comment_references_use_the_documents_annotation_reference_style() {
|
||||
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
|
||||
// A document without that style keeps the English id.
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
|
||||
doc.body = rebuilt.body.clone();
|
||||
doc.parts = rebuilt.parts.clone();
|
||||
@@ -315,7 +314,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
|
||||
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
|
||||
let mut copy = doc.numbering.abstracts[0].clone();
|
||||
copy.id = 7;
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(copy);
|
||||
doc.numbering.abstracts.push(copy);
|
||||
let n = text(&save(&doc), "word/numbering.xml");
|
||||
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ fn every_para_prop_round_trips() {
|
||||
#[test]
|
||||
fn styles_and_lists_round_trip() {
|
||||
let mut d = Document::new();
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
d.styles.upsert(Style {
|
||||
id: "MyStyle".into(),
|
||||
name: "My Style".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -265,7 +265,7 @@ fn styles_and_lists_round_trip() {
|
||||
default: false,
|
||||
table: None,
|
||||
});
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
d.styles.upsert(Style {
|
||||
id: "MyStyleChar".into(),
|
||||
name: "My Style Char".into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -274,10 +274,10 @@ fn styles_and_lists_round_trip() {
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
});
|
||||
let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal);
|
||||
let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline);
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) {
|
||||
let bullets = d.numbering.add_list(ListKind::Bullet);
|
||||
let nums = d.numbering.add_list(ListKind::Legal);
|
||||
let outline = d.numbering.add_list(ListKind::Outline);
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
|
||||
n.start_overrides.push((0, 4));
|
||||
}
|
||||
let mut ps = Vec::new();
|
||||
@@ -567,7 +567,7 @@ fn table_style_formatting_round_trips() {
|
||||
band_size: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
d.styles.upsert(Style {
|
||||
id: "Whole".into(),
|
||||
name: "Whole".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -620,7 +620,7 @@ fn every_table_style_part_round_trips() {
|
||||
};
|
||||
st.set_table_part(part, f);
|
||||
}
|
||||
Arc::make_mut(&mut d.styles).upsert(st.clone());
|
||||
d.styles.upsert(st.clone());
|
||||
let r = rt(&d);
|
||||
let got = r.styles.get("Parts").unwrap();
|
||||
for part in TablePart::ALL {
|
||||
@@ -1708,7 +1708,7 @@ fn kitchen_sink() -> Document {
|
||||
let mut d = Document::new();
|
||||
let png = tiny_png();
|
||||
let key = d.add_media(png, "png");
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
|
||||
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
|
||||
@@ -1940,10 +1940,10 @@ fn list_level_overrides_round_trip() {
|
||||
use wordcraft_doc::numbering::{Counters, Level};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let mut d = Document::new();
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap();
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let restart = d.numbering.restart(num).unwrap();
|
||||
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) {
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
|
||||
n.level_overrides = vec![(1, own.clone())];
|
||||
}
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -32,7 +32,7 @@ fn sample() -> Document {
|
||||
d.core.title = "Garden <notes> & more".into();
|
||||
d.core.creator = "Ada".into();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
|
||||
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
@@ -223,7 +222,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// layout cache starts over (and the saved file would gain a style it doesn't use).
|
||||
fn add_entry_style(s: &mut Session) {
|
||||
if s.doc.styles.get(ENTRY_STYLE).is_none() {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(wordcraft_doc::Style {
|
||||
s.doc.styles.upsert(wordcraft_doc::Style {
|
||||
id: ENTRY_STYLE.into(),
|
||||
name: "List Entry".into(),
|
||||
kind: wordcraft_doc::StyleKind::Paragraph,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
//! typing and deleting commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlLock, ControlRange, ListItem, MAX_CONTROL_TEXT, MAX_LIST_ITEMS};
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::props::{CharProps, Rgb, TextColor};
|
||||
@@ -173,7 +172,7 @@ fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String {
|
||||
/// The placeholder style, added to the document when it hasn't one.
|
||||
fn ensure_placeholder_style(s: &mut Session) {
|
||||
if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() {
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(Style {
|
||||
s.doc.styles.styles.push(Style {
|
||||
id: PLACEHOLDER_STYLE.into(),
|
||||
name: PLACEHOLDER_STYLE_NAME.into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -800,8 +799,6 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod editing_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::control::ControlKind;
|
||||
@@ -832,8 +829,8 @@ mod editing_tests {
|
||||
let typed_style = |run_props: Option<CharProps>| {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "name"}));
|
||||
let start = s.doc.control_at(&s.sel.focus).unwrap().start;
|
||||
@@ -855,7 +852,7 @@ mod editing_tests {
|
||||
// style goes all the same.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
run(&mut s, "text.insert", json!({"text": "Nom : "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "nom"}));
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
@@ -96,8 +95,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
s.doc.settings.major_font = heading.into();
|
||||
s.doc.settings.minor_font = body.into();
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.font = Some(body.into());
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
s.doc.styles.default_chr.font = Some(body.into());
|
||||
for st in &mut s.doc.styles.styles {
|
||||
if let Some(f) = &st.chr.font
|
||||
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
|
||||
{
|
||||
@@ -109,7 +108,7 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
}
|
||||
|
||||
fn set_accent(s: &mut Session, accent: Rgb) {
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
for st in &mut s.doc.styles.styles {
|
||||
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|
||||
|| st.id == "IntenseQuote"
|
||||
|| st.id == "IntenseEmphasis"
|
||||
@@ -174,16 +173,15 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
let sheet = Arc::make_mut(&mut s.doc.styles);
|
||||
for mut st in fresh.styles {
|
||||
match sheet.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => sheet.styles.push(st),
|
||||
None => s.doc.styles.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
@@ -202,9 +200,9 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"double" => (0.0, 8.0, 2.0),
|
||||
_ => (0.0, 8.0, 1.15),
|
||||
};
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_before = Some(before);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_after = Some(after);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
s.doc.styles.default_para.space_before = Some(before);
|
||||
s.doc.styles.default_para.space_after = Some(after);
|
||||
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -229,7 +227,7 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = Arc::make_mut(&mut s.doc.styles);
|
||||
let st = &mut s.doc.styles;
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{Block, Document, StoryRef};
|
||||
|
||||
use super::{pos_json, sel_result};
|
||||
@@ -128,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
|
||||
.params(
|
||||
r#"{"path": string, "pagesPerSheet"?: 1 | 2 | 4 | 6 | 9 | 16 | "booklet" | "bookletReverse", "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||||
@@ -231,7 +230,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang = Some(lang.clone());
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -272,7 +271,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
|
||||
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -212,25 +212,6 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
/// another author's is superseded.
|
||||
pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) };
|
||||
let revisions = s.doc.revisions.clone();
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None };
|
||||
if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) {
|
||||
let p = s.doc.para_mut(story, path)?;
|
||||
p.props.fmt_change = want;
|
||||
p.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
|
||||
/// differently from `old` as tracked formatting changes by `author`.
|
||||
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`; and
|
||||
//! the fonts the document's text asks for that aren't installed, with the fonts drawn instead
|
||||
//! (`font.missing`).
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||||
//!
|
||||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||||
@@ -8,9 +6,7 @@
|
||||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::{FontDb, UserFont};
|
||||
use wordcraft_layout::DocLayout;
|
||||
|
||||
use crate::cmd::insert::base64_decode;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -23,9 +19,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
|
||||
CommandSpec::new("font.missing", "Missing Fonts", "Status Bar", missing)
|
||||
.params(r#"{} → {"missing": [{"font": string, "shownAs": [string] (the families drawn instead), "metricCompatible": bool (its substitute measures like the font)}]} — the text the current view shows"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -124,102 +117,6 @@ fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A font the document's text asks for that isn't installed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MissingFont {
|
||||
/// The name the document asks for.
|
||||
pub font: String,
|
||||
/// The families its text is drawn in instead: the substitute, and fallback faces for
|
||||
/// characters the substitute lacks.
|
||||
pub shown_as: Vec<String>,
|
||||
/// The font's substitute measures like it (Carlito for Calibri…): line breaks don't move.
|
||||
/// Fallback faces for characters the substitute lacks don't count: the font itself might
|
||||
/// lack them too (an emoji in Calibri text).
|
||||
pub metric_compatible: bool,
|
||||
}
|
||||
|
||||
/// The family text set in `font` is drawn in when `font` isn't installed (None when it is), as
|
||||
/// layout picks it for `doc`.
|
||||
pub fn shown_as(doc: &Document, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::substitute_face(doc, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// The family Chinese, Japanese and Korean text is drawn in when its East Asian font `font_ea`
|
||||
/// isn't installed (None when it is), the run's font being `font`, as layout picks it for `doc`.
|
||||
pub fn east_asian_shown_as(doc: &Document, font_ea: &str, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::east_asian_substitute_face(doc, font_ea, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// Whether text in `font` drawn in `substitute` is worth a warning: the substitute measures
|
||||
/// differently, so line breaks can move, and the font isn't a symbol font (drawn as its
|
||||
/// Unicode equivalents on purpose, which layout leaves out of [`missing_fonts`] too). Decides
|
||||
/// [`MissingFont::metric_compatible`], which the font box and the status bar both read.
|
||||
pub fn warns(font: &str, substitute: &str) -> bool {
|
||||
!wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::metric_compatible(font, substitute)
|
||||
}
|
||||
|
||||
/// The fonts `doc`'s laid-out text asks for that aren't installed, from what layout drew
|
||||
/// ([`DocLayout::substituted_fonts`]): only text the current view shows counts (no headers or
|
||||
/// footers in Web, Draft or Read view), in every story on its pages, list labels and field
|
||||
/// results included. Names differing only in case are one font.
|
||||
pub fn missing_fonts(doc: &Document, layout: &DocLayout) -> Vec<MissingFont> {
|
||||
// By key: in name order, case variants merged.
|
||||
let mut out: std::collections::BTreeMap<String, MissingFont> = std::collections::BTreeMap::new();
|
||||
for (recorded, face) in layout.substituted_fonts() {
|
||||
let family = face.get().family.clone();
|
||||
let font = shown_name(&recorded);
|
||||
let m = out.entry(font_key(font)).or_insert_with(|| MissingFont {
|
||||
font: font.to_string(),
|
||||
shown_as: Vec::new(),
|
||||
// Judged by the name layout looked up, spaces and all: "Arial " isn't Arial to it, so
|
||||
// its text is in a substitute even where Arial is installed.
|
||||
metric_compatible: shown_as(doc, &recorded).is_some_and(|s| !warns(font, &s)),
|
||||
});
|
||||
if !m.shown_as.contains(&family) {
|
||||
m.shown_as.push(family);
|
||||
}
|
||||
}
|
||||
out.into_values().collect()
|
||||
}
|
||||
|
||||
/// A font name as [`missing_fonts`] lists it: as layout records it (at most 256 characters),
|
||||
/// trimmed.
|
||||
fn shown_name(font: &str) -> &str {
|
||||
wordcraft_layout::para::reported_font_name(font).trim()
|
||||
}
|
||||
|
||||
/// The key a font name is listed under in [`missing_fonts`]: two names with the same key are
|
||||
/// one font. Look a document's font up in the list through this.
|
||||
pub fn font_key(font: &str) -> String {
|
||||
shown_name(font).to_lowercase()
|
||||
}
|
||||
|
||||
/// The status line's form of a font name: a hostile file's names are long.
|
||||
fn short_name(font: &str) -> String {
|
||||
match font.char_indices().nth(40) {
|
||||
Some((i, _)) => format!("{}…", font.get(..i).unwrap_or(font)),
|
||||
None => font.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let layout = s.layout();
|
||||
let found = missing_fonts(&s.doc, &layout);
|
||||
// From command search there's no dialog: the status line says what was found.
|
||||
// A hostile file can name thousands of fonts: name the first few.
|
||||
const NAMED: usize = 5;
|
||||
let changing: Vec<&str> = found.iter().filter(|f| !f.metric_compatible).map(|f| f.font.as_str()).collect();
|
||||
let named = changing.iter().take(NAMED).map(|f| short_name(f)).collect::<Vec<_>>().join(", ");
|
||||
s.status = match (found.is_empty(), changing.len()) {
|
||||
(true, _) => "Every font the document shows is installed".into(),
|
||||
(false, 0) => "Fonts that aren't installed are shown in fonts that measure the same: line breaks don't move".into(),
|
||||
(_, n) if n > NAMED => format!("Not installed: {named} and {} more", n - NAMED),
|
||||
_ => format!("Not installed: {named}"),
|
||||
};
|
||||
let fonts: Vec<Value> = found.iter().map(|f| json!({"font": f.font, "shownAs": f.shown_as, "metricCompatible": f.metric_compatible})).collect();
|
||||
Ok(json!({"missing": fonts}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -866,14 +866,8 @@ pub fn state(s: &Session) -> Value {
|
||||
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
|
||||
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
|
||||
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
|
||||
let font = same(&|r| json!(r.font));
|
||||
// The font's substitute when it isn't installed: the face its text is drawn in, apart from
|
||||
// characters the substitute lacks. `font.missing` lists every face the shown text is drawn
|
||||
// in, fallback faces too, and which fonts change the layout.
|
||||
let shown_as = font.as_str().and_then(|f| crate::cmd::font::shown_as(&s.doc, f));
|
||||
json!({
|
||||
"font": font,
|
||||
"fontShownAs": shown_as,
|
||||
"font": same(&|r| json!(r.font)),
|
||||
"fontEastAsia": same(&|r| json!(r.font_ea)),
|
||||
"size": same(&|r| json!(r.size)),
|
||||
"bold": all.iter().all(|r| r.bold),
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
|
||||
|
||||
use super::references::{FieldCode, field_arg, leader};
|
||||
use super::references::{FieldCode, leader};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
@@ -44,12 +44,11 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
|
||||
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
|
||||
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
|
||||
instr.push_str(&format!(" \\t \"{see}\""));
|
||||
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
|
||||
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
|
||||
}
|
||||
// A name read from a file may hold a space, a quote or a backslash.
|
||||
instr.push_str(&format!(" \\r {}", field_arg(bm)));
|
||||
instr.push_str(&format!(" \\r {}", bm.trim()));
|
||||
}
|
||||
if p::bool(v, "bold").unwrap_or(false) {
|
||||
instr.push_str(" \\b");
|
||||
@@ -394,36 +393,6 @@ mod tests {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
|
||||
}
|
||||
|
||||
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
|
||||
/// can hold) is quoted in the XE field, so it reads back whole.
|
||||
#[test]
|
||||
fn mark_entry_trims_and_quotes_its_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
|
||||
s.run("select.text", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
|
||||
let odd = "My \"Mark\" \\b";
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
|
||||
let mut instrs: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
instrs.sort();
|
||||
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
|
||||
let beta = FieldCode::parse(&instrs[1]);
|
||||
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
|
||||
}
|
||||
|
||||
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
|
||||
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
|
||||
/// before right-aligned page numbers, and two columns in a continuous section.
|
||||
|
||||
@@ -137,8 +137,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
if let Some(want) = width {
|
||||
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
|
||||
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
|
||||
/// writes as `null` and can't read back.
|
||||
#[test]
|
||||
fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
|
||||
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
|
||||
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
|
||||
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
|
||||
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
|
||||
}
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! the number and the number's font.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
|
||||
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
|
||||
@@ -184,26 +183,26 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(n) => {
|
||||
// Change the list in place: every list sharing its definition follows.
|
||||
let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?;
|
||||
if let Some(a) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
a.levels = levels.clone();
|
||||
if name.is_some() {
|
||||
a.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == n) {
|
||||
if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) {
|
||||
x.level_overrides.clear();
|
||||
}
|
||||
let sharing: Vec<u32> = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect();
|
||||
touch_lists(s, &sharing)?;
|
||||
n
|
||||
}
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_levels(name, levels.clone())?,
|
||||
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
|
||||
};
|
||||
// Levels linked to a paragraph style number every paragraph in that style.
|
||||
let mut linked = Vec::new();
|
||||
for (i, l) in levels.iter().enumerate() {
|
||||
let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue };
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: i as u8 });
|
||||
linked.push(id);
|
||||
}
|
||||
@@ -293,11 +292,11 @@ fn preset_levels(preset: &str) -> Option<Vec<Level>> {
|
||||
fn preset(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = p::req_str(v, "preset")?;
|
||||
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).try_add_levels(None, levels)?;
|
||||
let num = s.doc.numbering.try_add_levels(None, levels)?;
|
||||
let mut linked = Vec::new();
|
||||
for n in 1..=LEVELS as u8 {
|
||||
let id = super::para::ensure_heading_style(s, n);
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: n - 1 });
|
||||
linked.push(id);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use wordcraft_doc::props::{Align, CharProps};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::references::{field_arg, field_words, quoted};
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -123,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
|
||||
let f = p::req_str(v, "field")?.to_string();
|
||||
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
|
||||
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
|
||||
})
|
||||
.params(r#"{"field": string}"#)
|
||||
.key(super::non_mac("Alt+Shift+F")),
|
||||
@@ -137,14 +136,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
|
||||
let instr = match rule.as_str() {
|
||||
"IF" => format!(
|
||||
"IF {{ MERGEFIELD {} }} = {} {} {}",
|
||||
field_arg(p::str(v, "field").unwrap_or("Field")),
|
||||
quoted(p::str(v, "value").unwrap_or("")),
|
||||
quoted(p::str(v, "then").unwrap_or("")),
|
||||
quoted(p::str(v, "else").unwrap_or(""))
|
||||
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
|
||||
quote(p::str(v, "field").unwrap_or("Field")),
|
||||
p::str(v, "value").unwrap_or(""),
|
||||
p::str(v, "then").unwrap_or(""),
|
||||
p::str(v, "else").unwrap_or("")
|
||||
),
|
||||
"SKIPIF" | "NEXTIF" => {
|
||||
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
|
||||
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
|
||||
}
|
||||
"MERGEREC" => "MERGEREC".into(),
|
||||
_ => "NEXT".into(),
|
||||
@@ -228,6 +227,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn quote(f: &str) -> String {
|
||||
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
|
||||
field_words, json, p, para_block,
|
||||
json, p, para_block,
|
||||
};
|
||||
|
||||
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
@@ -41,7 +41,9 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
|
||||
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
|
||||
return None;
|
||||
}
|
||||
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
|
||||
let rest = rest.trim();
|
||||
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Which columns hold address parts.
|
||||
@@ -101,17 +103,17 @@ fn rule_field(t: &str) -> Option<String> {
|
||||
|
||||
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
|
||||
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
|
||||
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
|
||||
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
let field = rule_field(t).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
|
||||
let cmp = texts.next().unwrap_or_default();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
let cmp = quoted.next().unwrap_or("");
|
||||
let v = value(s, row, &field);
|
||||
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
|
||||
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => a.partial_cmp(&b),
|
||||
_ => Some(v.as_str().cmp(cmp.as_str())),
|
||||
_ => Some(v.as_str().cmp(cmp)),
|
||||
};
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let holds = match op.as_str() {
|
||||
@@ -122,7 +124,7 @@ fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
|
||||
">=" => matches!(ord, Some(Greater | Equal)),
|
||||
_ => ord == Some(Equal),
|
||||
};
|
||||
(holds, texts.collect())
|
||||
(holds, quoted.collect())
|
||||
}
|
||||
|
||||
/// The first word of a field instruction, upper-cased.
|
||||
@@ -147,7 +149,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
|
||||
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
|
||||
"IF" if rule_field(t).is_some() => {
|
||||
let (holds, texts) = condition(s, t, row);
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -307,29 +307,6 @@ fn if_rule_previews_its_result() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
|
||||
}
|
||||
|
||||
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
|
||||
#[test]
|
||||
fn rule_texts_keep_their_quotes() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
|
||||
s.run("mailings.rules", &rule).unwrap();
|
||||
s.run("mailings.preview", &json!({"value": true})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
|
||||
s.run("mailings.next", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
|
||||
// SKIPIF compares with the value as typed.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
|
||||
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
|
||||
}
|
||||
|
||||
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
|
||||
/// own header.
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("proof.packs", "Language Packs", "File › Options › Language", list)
|
||||
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "languages": [{"tags", "pack", "installed", "size", "proofing" (built_in, installed, own_dictionary, system_dictionary, install, grammar_built_in, not_available), "default"}] (Options › Language › Your languages), "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
|
||||
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("proof.installPack", "Install Language Pack", "File › Options › Language", install)
|
||||
.params(r#"{"locale": string (a pack's locale from proof.packs, like nl or pt-pt)} — downloads, verifies (sha256) and installs it; spelling and hyphenation in that language use it at once. Needs downloading on (only the person in the app can turn it on). Opens nothing. In the app it runs in the background and returns {"started": true}: follow it with proof.packs ("installing", then "installed" or "errors"); elsewhere it returns when installed."#)
|
||||
@@ -33,7 +33,7 @@ fn locale_param(v: &Value) -> Result<String, CmdError> {
|
||||
packs::parse_locale(raw)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "refresh") == Some(true) {
|
||||
packs::start_refresh().map_err(CmdError::Failed)?;
|
||||
if let Some(e) = packs::error("") {
|
||||
@@ -55,7 +55,6 @@ fn list(s: &mut Session, v: &Value) -> CmdResult {
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"packs": rows,
|
||||
"languages": packs::your_languages(s),
|
||||
"installed": installed.iter().map(|i| json!({"locale": i.locale, "name": i.name, "licence": i.licence})).collect::<Vec<_>>(),
|
||||
"installing": packs::installing(),
|
||||
"refreshing": packs::fetching(),
|
||||
@@ -163,166 +162,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Options › Language › Your languages: fresh, with Dutch installed, a document with Thai,
|
||||
/// Turkish without a pack, and en-GB as the default.
|
||||
mod your_languages {
|
||||
use super::*;
|
||||
use crate::packs::Proofing;
|
||||
|
||||
fn none(_: &str) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn row<'a>(rows: &'a [packs::LanguageRow], tag: &str) -> &'a packs::LanguageRow {
|
||||
rows.iter().find(|r| r.is_tag(tag) || r.pack.as_deref() == Some(tag)).unwrap_or_else(|| panic!("no row for {tag}: {rows:?}"))
|
||||
}
|
||||
|
||||
fn mark(s: &mut Session, para: usize, lang: &str) {
|
||||
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": lang})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_install_lists_only_english() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!(rows[0].proofing, Proofing::BuiltIn);
|
||||
assert!(rows[0].default);
|
||||
assert_eq!(rows[0].pack, None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_pack_is_a_row_with_remove() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(rows.len(), 2);
|
||||
let nl = row(&rows, "nl");
|
||||
assert!(nl.installed && nl.tags.is_empty() && !nl.default);
|
||||
assert_eq!(nl.proofing, Proofing::Installed);
|
||||
let r = s.run("proof.packs", &json!({})).unwrap();
|
||||
assert_eq!(r["languages"].as_array().unwrap().len(), 2, "MCP sees the same rows");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_languages_show_install_grammar_and_not_available() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Hallo\nMerhaba\nสวัสดี\nCześć\nBonjour"})).unwrap();
|
||||
mark(s, 0, "nl-NL");
|
||||
mark(s, 1, "tr-TR");
|
||||
mark(s, 2, "th-TH");
|
||||
mark(s, 3, "pl-PL");
|
||||
mark(s, 4, "fr-FR");
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let nl = row(&rows, "nl-NL");
|
||||
assert_eq!((nl.proofing, nl.size, nl.installed), (Proofing::Install, 1_950_000, false));
|
||||
// Turkish has grammar rules built in and no pack in the sample index.
|
||||
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::NotAvailable);
|
||||
assert_eq!(row(&rows, "th-TH").proofing, Proofing::NotAvailable);
|
||||
let pl = row(&rows, "pl-PL");
|
||||
assert_eq!((pl.proofing, pl.size), (Proofing::GrammarBuiltIn, 4_620_000));
|
||||
// Installing turns Install into Installed; the document keeps the row.
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Installed);
|
||||
s.run("proof.removePack", &json!({"locale": "nl"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Install, "still used by the document");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valencian_text_is_on_the_valencian_packs_row() {
|
||||
let mut b = FixedBackend::sample();
|
||||
let mut v = b.packs.first().cloned().unwrap();
|
||||
v.locale = "ca-valencia".into();
|
||||
v.size = 1_500_000;
|
||||
b.packs.push(v);
|
||||
with_backend(b, |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Bon dia"})).unwrap();
|
||||
mark(s, 0, "ca-ES-valencia");
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
assert_eq!(row(&rows, "ca-ES-valencia").pack.as_deref(), Some("ca-valencia"), "as the bar offers it");
|
||||
// Installing the Catalan pack doesn't make Valencian installed.
|
||||
b.network.store(true, Ordering::Relaxed);
|
||||
s.run("proof.installPack", &json!({"locale": "ca"})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let v = row(&rows, "ca-ES-valencia");
|
||||
assert_eq!((v.pack.as_deref(), v.installed, v.proofing), (Some("ca-valencia"), false, Proofing::Install));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_own_folder_and_the_default_language() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Cześć\nMerhaba"})).unwrap();
|
||||
mark(s, 0, "pl-PL");
|
||||
mark(s, 1, "tr-TR");
|
||||
let dic = |tag: &str| {
|
||||
if tag.starts_with("pl") {
|
||||
Some(std::path::PathBuf::from("/mine/dicts/pl_PL.dic"))
|
||||
} else if tag.starts_with("tr") {
|
||||
Some(std::path::PathBuf::from("/usr/share/hunspell/tr_TR.dic"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let rows = packs::your_languages_with(s, Some("/mine/dicts"), &dic);
|
||||
assert_eq!(row(&rows, "pl-PL").proofing, Proofing::OwnDictionary);
|
||||
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::SystemDictionary);
|
||||
// en-GB as the default is its own row, the default one; en-US stays if the document uses it.
|
||||
s.run("review.language", &json!({"lang": "en-GB", "default": true})).unwrap();
|
||||
let rows = packs::your_languages_with(s, None, &none);
|
||||
let gb = row(&rows, "en-GB");
|
||||
assert!(gb.default && gb.proofing == Proofing::BuiltIn);
|
||||
assert_eq!(rows.iter().filter(|r| r.default).count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_as_default_sets_the_pref_and_nothing_else() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
|
||||
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
|
||||
// A redo step exists: the setting is no edit, so it neither adds an undo step nor clears it.
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
let rev = s.rev();
|
||||
let (depth, redo, dirty) = (s.undo_depth(), s.can_redo(), s.dirty);
|
||||
assert!(redo);
|
||||
s.run("review.language.preferred", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert_eq!((s.undo_depth(), s.can_redo(), s.dirty), (depth, redo, dirty), "history, redo and modified flag are kept");
|
||||
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
|
||||
assert_eq!(s.doc.styles.default_chr.lang, doc);
|
||||
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
|
||||
assert_eq!(s.sel, sel);
|
||||
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
|
||||
assert_eq!(s.rev(), rev);
|
||||
assert!(s.run("review.language.preferred", &json!({})).is_err());
|
||||
assert!(s.run("review.language.preferred", &json!({"lang": "not a tag!!"})).is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tags_never_panic() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "a b"})).unwrap();
|
||||
s.prefs.default_language = Some("x".repeat(10_000));
|
||||
let _ = packs::your_languages_with(s, Some(""), &none);
|
||||
s.prefs.default_language = Some("\u{0}-\u{202e}".into());
|
||||
let _ = packs::your_languages_with(s, None, &none);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_hostile_locales_are_errors() {
|
||||
with_backend(FixedBackend::sample(), |s, b| {
|
||||
@@ -390,21 +229,6 @@ mod tests {
|
||||
assert_eq!(packs::pack_for_tag("pt-PT", &packs).map(|p| p.locale.as_str()), Some("pt-pt"));
|
||||
assert_eq!(packs::pack_for_tag("pt_BR", &packs).map(|p| p.locale.as_str()), Some("pt"));
|
||||
assert_eq!(packs::pack_for_tag("nl-BE", &packs).map(|p| p.locale.as_str()), Some("nl"));
|
||||
// A script-qualified tag finds its script's pack before the language's (CP4.3: Serbian
|
||||
// (Latin) chosen in the Language dialog is proofed by `sr-latn`, not the Cyrillic `sr`).
|
||||
packs.push(Pack { locale: "sr".into(), ..packs[0].clone() });
|
||||
packs.push(Pack { locale: "sr-latn".into(), ..packs[0].clone() });
|
||||
assert_eq!(packs::pack_for_tag("sr-Latn-RS", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
|
||||
assert_eq!(packs::pack_for_tag("sr-Cyrl-RS", &packs).map(|p| p.locale.as_str()), Some("sr"));
|
||||
assert_eq!(packs::pack_for_tag("sr-latn", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
|
||||
assert_eq!(packs::pack_for_tag("-", &packs), None);
|
||||
assert_eq!(packs::pack_for_tag("nl--", &packs).map(|p| p.locale.as_str()), Some("nl"));
|
||||
// A variant after the region is its language-and-variant pack's; the region alone isn't.
|
||||
packs.push(Pack { locale: "ca-valencia".into(), ..packs[0].clone() });
|
||||
assert_eq!(packs::pack_for_tag("ca-ES-valencia", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
|
||||
assert_eq!(packs::pack_for_tag("ca_es_VALENCIA", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
|
||||
assert_eq!(packs::pack_for_tag("ca-ES", &packs).map(|p| p.locale.as_str()), Some("ca"));
|
||||
assert_eq!(packs::pack_for_tag("ca", &packs).map(|p| p.locale.as_str()), Some("ca"));
|
||||
assert_eq!(packs::pack_for_tag("en-GB", &packs), None);
|
||||
assert_eq!(packs::pack_for_tag("", &packs), None);
|
||||
assert!(packs::valid_locale("sr-latn"));
|
||||
@@ -662,187 +486,5 @@ mod tests {
|
||||
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
|
||||
}
|
||||
|
||||
/// The one region test and region-dropping step the engine and the UI share: two letters or
|
||||
/// three digits after the language; hostile text never panics.
|
||||
#[test]
|
||||
fn a_region_is_two_letters_or_three_digits() {
|
||||
for r in ["NL", "nl", "419", "ZZ"] {
|
||||
assert!(packs::is_region(r), "{r}");
|
||||
}
|
||||
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
|
||||
assert!(!packs::is_region(r), "{r:?}");
|
||||
}
|
||||
for (tag, bare) in [
|
||||
("nl-NL", "nl"),
|
||||
("sr-Latn-RS", "sr-Latn"),
|
||||
("ca-ES-valencia", "ca-valencia"),
|
||||
("es_419", "es"),
|
||||
("pt", "pt"),
|
||||
("en-US-x-test", "en-x-test"),
|
||||
("", ""),
|
||||
("-", "-"),
|
||||
] {
|
||||
assert_eq!(packs::without_region(tag), bare, "{tag}");
|
||||
}
|
||||
// The language itself is never a region, even when it looks like one.
|
||||
assert_eq!(packs::without_region("de"), "de");
|
||||
let _ = packs::without_region(&"-".repeat(10_000));
|
||||
let _ = packs::without_region("\u{0}-\u{202e}");
|
||||
}
|
||||
|
||||
/// A font store that has never fetched the list (a fresh install), or whose cached list is
|
||||
/// for an older pin: `index` has nothing until a fetch.
|
||||
struct Uncached(FixedBackend);
|
||||
|
||||
impl Backend for Uncached {
|
||||
fn index(&self) -> Result<Vec<Pack>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn fetch_index(&self, c: &packs::Cancel) -> Result<Vec<Pack>, String> {
|
||||
self.0.fetch_index(c)
|
||||
}
|
||||
fn network_on(&self) -> bool {
|
||||
self.0.network_on()
|
||||
}
|
||||
fn enable_network(&self) -> Result<(), String> {
|
||||
self.0.enable_network()
|
||||
}
|
||||
fn install(&self, pack: &Pack, cancel: &packs::Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
|
||||
self.0.install(pack, cancel, progress)
|
||||
}
|
||||
fn remove(&self, locale: &str) -> Result<(), String> {
|
||||
self.0.remove(locale)
|
||||
}
|
||||
fn packs_dir(&self) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
fn installed(&self) -> Vec<packs::Installed> {
|
||||
self.0.installed()
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample packs and Greenlandic (`kl`), a language no system has a dictionary for.
|
||||
fn with_greenlandic() -> FixedBackend {
|
||||
let mut b = FixedBackend::sample();
|
||||
let kl = Pack { locale: "kl".into(), name: "Kalaallisut".into(), ..b.packs[0].clone() };
|
||||
b.packs.push(kl);
|
||||
b
|
||||
}
|
||||
|
||||
fn with_uncached<T>(b: FixedBackend, f: impl FnOnce(&mut Session, &Uncached) -> T) -> T {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let b = Arc::new(Uncached(b));
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
f(&mut s, &b)
|
||||
}
|
||||
|
||||
/// Martin's test of a fresh install: Greenlandic text, no list fetched yet. The bar still
|
||||
/// offers the language (never English, nor one with a dictionary); getting it fetches the
|
||||
/// list and installs its pack. A language the list then has no pack for (Klingon) stops
|
||||
/// being offered.
|
||||
#[test]
|
||||
fn with_no_list_yet_the_bar_still_offers_the_language() {
|
||||
with_uncached(with_greenlandic(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "Hello there\nAluu, qanoq ippit?\ntlhIngan Hol"})).unwrap();
|
||||
for (para, lang) in [(1, "kl-GL"), (2, "tlh")] {
|
||||
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
|
||||
s.run("select.paragraph", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": lang})).unwrap();
|
||||
}
|
||||
assert!(packs::available().is_empty() && packs::missing(s).is_empty(), "no list: no pack to offer");
|
||||
for tag in ["kl-GL", "tlh"] {
|
||||
assert!(wordcraft_proof::missing_dictionary(Some(tag)).is_some(), "{tag} has no dictionary here");
|
||||
}
|
||||
assert_eq!(packs::unlisted(s), ["kl-GL", "tlh"], "English is never offered");
|
||||
// Downloading off: getting it fails up front, and asks for nothing.
|
||||
assert!(packs::start_get("kl-GL").is_err());
|
||||
assert!(!packs::fetched());
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_get("kl-GL"), Ok(false), "fetched and installed here (no background thread)");
|
||||
assert!(packs::is_installed("kl"));
|
||||
assert!(packs::fetched());
|
||||
assert!(packs::unlisted(s).is_empty(), "the list is known now: Klingon, which it lacks, isn't offered");
|
||||
assert!(packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// Review: a get refused because downloading is off isn't remembered, so a later fetch (the
|
||||
/// person's Refresh in Options) doesn't install it unasked.
|
||||
#[test]
|
||||
fn a_refused_get_is_not_installed_by_a_later_fetch() {
|
||||
with_uncached(with_greenlandic(), |_, b| {
|
||||
assert!(packs::start_get("kl-GL").is_err(), "downloading off");
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_refresh(), Ok(false));
|
||||
assert!(packs::find("kl").is_some(), "the list has it");
|
||||
assert!(!packs::is_installed("kl"), "but nobody asked for it after the refusal");
|
||||
});
|
||||
}
|
||||
|
||||
/// The list was fetched, but lists nothing for the language: the bar stops offering it.
|
||||
#[test]
|
||||
fn a_language_the_fetched_list_lacks_is_no_longer_offered() {
|
||||
with_uncached(FixedBackend::sample(), |s, b| {
|
||||
s.run("document.setText", &json!({"text": "tlhIngan Hol"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": "tlh"})).unwrap();
|
||||
assert_eq!(packs::unlisted(s), ["tlh"]);
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert_eq!(packs::start_get("tlh"), Ok(false));
|
||||
assert!(packs::installed().is_empty(), "nothing to install");
|
||||
assert!(packs::unlisted(s).is_empty() && packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
/// With downloading already on and no list (or one for an older pin), the window fetches it
|
||||
/// once in the background, asking nothing; never with downloading off, never twice.
|
||||
#[test]
|
||||
fn a_missing_list_is_fetched_once_when_downloading_is_on() {
|
||||
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _reset = Reset;
|
||||
let b = Arc::new(Uncached(with_greenlandic()));
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
assert!(!packs::refresh_if_stale(), "no background thread (the CLI, MCP): never");
|
||||
packs::set_background(Some(Arc::new(|| {})));
|
||||
assert!(!packs::refresh_if_stale(), "downloading off: no request");
|
||||
b.0.network.store(true, Ordering::Relaxed);
|
||||
assert!(!packs::refresh_if_stale(), "asked once a session: turned on later, the person's own click fetches");
|
||||
// A new session (the backend set again) with downloading on.
|
||||
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Aluu"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("review.language", &json!({"lang": "kl"})).unwrap();
|
||||
let before = packs::finished();
|
||||
assert!(packs::refresh_if_stale());
|
||||
wait_for_finish(before);
|
||||
assert!(packs::fetched());
|
||||
assert_eq!(packs::missing(&s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["kl"], "the bar offers the pack");
|
||||
assert!(!packs::refresh_if_stale(), "once a session");
|
||||
assert!(packs::installed().is_empty(), "nothing installed without a click");
|
||||
}
|
||||
|
||||
/// Martin's test: choosing a language with nothing selected (the caret only) sets the typing
|
||||
/// language; the bar offers its pack before anything is typed.
|
||||
#[test]
|
||||
fn the_caret_typing_language_asks_for_its_pack() {
|
||||
with_backend(FixedBackend::sample(), |s, _| {
|
||||
s.run("document.setText", &json!({"text": "Hello there"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 11}})).unwrap();
|
||||
assert!(packs::missing(s).is_empty());
|
||||
s.run("review.language", &json!({"lang": "nl-NL"})).unwrap();
|
||||
assert!(s.sel.is_collapsed());
|
||||
assert!(!packs::document_languages(s).iter().any(|l| l == "nl-NL"), "no Dutch text yet");
|
||||
assert_eq!(packs::caret_language(s).as_deref(), Some("nl-NL"));
|
||||
assert_eq!(packs::missing(s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["nl"]);
|
||||
// Do not check spelling or grammar at the caret: nothing asked.
|
||||
s.run("review.language", &json!({"lang": "nl-NL", "noProof": true})).unwrap();
|
||||
assert_eq!(packs::caret_language(s), None);
|
||||
assert!(packs::missing(s).is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
use crate::packs::Pack;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
@@ -257,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let on = p::bool(v, "value").unwrap_or(true);
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.quick = on;
|
||||
}
|
||||
sel_result(s)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::format::fmt;
|
||||
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
|
||||
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
@@ -46,9 +45,9 @@ pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
None => match kind {
|
||||
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
|
||||
Some(n) => n,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
},
|
||||
_ => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
_ => s.doc.numbering.try_add_list(kind)?,
|
||||
},
|
||||
};
|
||||
fmt(s, &|p| {
|
||||
@@ -69,7 +68,7 @@ pub(super) fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
|
||||
return Err(CmdError::Failed("not in a numbered list".into()));
|
||||
};
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
// This paragraph and the following ones of the same list use the new instance.
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
|
||||
for path in paths {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::lists::touch_all;
|
||||
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
|
||||
/// touches swap places with the paragraph above or below, in their own container (the story or
|
||||
@@ -97,7 +96,7 @@ pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
|
||||
if s.doc.styles.get(&sid).is_none()
|
||||
&& let Some(st) = builtin.get(&sid).cloned()
|
||||
{
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.doc.styles.upsert(st);
|
||||
}
|
||||
}
|
||||
touch_all(s);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::format::fmt;
|
||||
use super::lists::touch_all;
|
||||
use super::{CharProps, CmdError, CmdResult, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
|
||||
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
|
||||
@@ -102,7 +101,7 @@ pub(super) fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
st.chr.fmt_change = None;
|
||||
st.para = st.para.formatting();
|
||||
}
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.doc.styles.upsert(st);
|
||||
apply_style(s, &json!({"style": id}))?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -114,7 +113,7 @@ pub(super) fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let next = p::str(v, "next").map(str::to_string);
|
||||
let name = p::str(v, "name").map(str::to_string);
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if let Some(c) = chr {
|
||||
st.chr.overlay(&c);
|
||||
}
|
||||
@@ -176,7 +175,7 @@ pub(super) fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?;
|
||||
let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() };
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.chr = chr;
|
||||
st.para = pp;
|
||||
}
|
||||
@@ -264,7 +263,7 @@ pub(super) fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let mut changed = 0;
|
||||
for g in &gone {
|
||||
Arc::make_mut(&mut s.doc.styles).remove(&g.id);
|
||||
s.doc.styles.remove(&g.id);
|
||||
// Fall back to the base only while it is a style of the same kind that still exists.
|
||||
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
|
||||
changed += s.doc.restyle(&g.id, base);
|
||||
@@ -280,7 +279,7 @@ pub(super) fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if gallery.is_none() && hidden.is_none() {
|
||||
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
|
||||
}
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
if let Some(h) = hidden {
|
||||
st.hidden = h;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::{InlineObject, OBJ};
|
||||
use wordcraft_doc::props::{CellProps, CharProps, RowProps, TableProps};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Path, PermRange, Pos, StoryRef};
|
||||
@@ -79,7 +78,7 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
|
||||
}
|
||||
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
for st in &mut s.doc.styles.styles {
|
||||
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
|
||||
#[test]
|
||||
fn formatting_restriction_refuses_attach_template_update() {
|
||||
let mut t = Document::from_text("Template.");
|
||||
if let Some(st) = Arc::make_mut(&mut t.styles).styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
if let Some(st) = t.styles.styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
st.chr.size = Some(31.0);
|
||||
}
|
||||
let data = crate::cmd::insert::base64_encode(&crate::io::save_bytes("t.docx", &t).unwrap());
|
||||
@@ -147,7 +147,7 @@ fn formatting_restriction_refuses_attach_template_update() {
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
// Copying styles in replaces their definitions, locked flag included (#3692).
|
||||
Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
|
||||
@@ -16,9 +16,7 @@ mod properties;
|
||||
mod toc;
|
||||
|
||||
use fields::caption;
|
||||
pub use fields::{
|
||||
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq,
|
||||
};
|
||||
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
|
||||
pub(super) use notes::note_options;
|
||||
use notes::{note, note_refs};
|
||||
pub use properties::{bookmark_text, date_picture};
|
||||
@@ -112,61 +110,37 @@ pub struct FieldCode {
|
||||
pub switches: Vec<(char, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]).
|
||||
pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// `s` as quoted field-code text, which [`field_words`] reads back as it was.
|
||||
pub fn quoted(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '"' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`].
|
||||
pub fn field_arg(s: &str) -> String {
|
||||
if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() }
|
||||
}
|
||||
|
||||
impl FieldCode {
|
||||
pub fn parse(instr: &str) -> FieldCode {
|
||||
let mut words = field_words(instr).into_iter().peekable();
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
let mut words = words.into_iter().peekable();
|
||||
let mut f =
|
||||
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
|
||||
while let Some((w, quoted)) = words.next() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::properties::{
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
|
||||
};
|
||||
use super::toc::{heading_style_level, update_toc};
|
||||
use super::{
|
||||
@@ -77,219 +76,66 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
|
||||
/// text or number of the nearest paragraph of a style.
|
||||
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
|
||||
let numbers = seq_results(&s.doc);
|
||||
apply_results(&mut s.doc, numbers)?;
|
||||
let refs = styleref_results(&s.doc);
|
||||
apply_results(&mut s.doc, refs)
|
||||
}
|
||||
|
||||
/// A field's fresh result: its story and paragraph, its object index there and the text.
|
||||
type FieldResult = (StoryRef, Path, usize, String);
|
||||
|
||||
/// The SEQ numbering so far while the document is walked in order.
|
||||
#[derive(Default)]
|
||||
struct SeqCount {
|
||||
/// How many headings of each level there have been (for `\s`).
|
||||
headings: [u32; 9],
|
||||
/// Per sequence: its number, and the heading count it was last restarted at.
|
||||
seq: std::collections::HashMap<String, (u32, u32)>,
|
||||
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
|
||||
boxes: std::collections::HashSet<u32>,
|
||||
out: Vec<FieldResult>,
|
||||
}
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let mut headings = [0u32; 9];
|
||||
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
|
||||
// Built on the first STYLEREF (field results don't change what it finds).
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(l) = heading_style_level(p)
|
||||
&& let Some(k) = (l as usize).checked_sub(1)
|
||||
&& let Some(n) = count.headings.get_mut(k)
|
||||
&& let Some(count) = headings.get_mut(k)
|
||||
{
|
||||
*n = n.saturating_add(1);
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
count_seq(doc, StoryRef::Body, &path, p, &mut count);
|
||||
}
|
||||
count.out
|
||||
}
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
for bp in doc.para_paths(part) {
|
||||
if let Some(boxed) = doc.para(part, &bp) {
|
||||
count_seq(doc, part, &bp, boxed, count);
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
match f.name.as_str() {
|
||||
"SEQ" => {
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart =
|
||||
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
|
||||
let n = seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
|
||||
}
|
||||
"STYLEREF" => {
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart = f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied();
|
||||
let n = count.seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
}
|
||||
}
|
||||
|
||||
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
|
||||
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
|
||||
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
// Built on the first STYLEREF.
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
if FieldCode::parse(instr).name != "STYLEREF" {
|
||||
continue;
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What Word prints for a `REF` to a bookmark the document doesn't have.
|
||||
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
|
||||
|
||||
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
|
||||
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
|
||||
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
|
||||
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
|
||||
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
|
||||
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
|
||||
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
|
||||
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
|
||||
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
|
||||
/// fields refer to it.
|
||||
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
|
||||
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
|
||||
// The first of two bookmarks with one name, as `bookmark_text` takes.
|
||||
starts.entry(name).or_insert(start);
|
||||
}
|
||||
// A bookmark's text, `None` when it ends in a later paragraph.
|
||||
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
|
||||
// here: those keep their stored result rather than print the paragraph's text.
|
||||
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
|
||||
continue;
|
||||
}
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let Some(start) = starts.get(target) else {
|
||||
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
|
||||
continue;
|
||||
};
|
||||
let text = texts.entry(target.to_string()).or_insert_with(|| {
|
||||
let source = if start.story == StoryRef::Body { doc } else { stored };
|
||||
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
|
||||
});
|
||||
if let Some(r) = text.clone() {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
|
||||
/// order.
|
||||
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for id in doc.parts.keys() {
|
||||
let story = StoryRef::Part(*id);
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
|
||||
/// Word's F9 clears it too).
|
||||
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
|
||||
for (story, path, k, r) in results {
|
||||
let para = doc.para_mut(story, &path)?;
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `results` less those its fields already show.
|
||||
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
|
||||
results.retain(|(story, path, k, r)| {
|
||||
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
|
||||
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
|
||||
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
|
||||
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
|
||||
/// fields whose stored results were wrong). The document itself keeps its stored results.
|
||||
/// `None` when every such field already shows its fresh result, so nothing is copied then.
|
||||
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
|
||||
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
|
||||
// and a REF to a bookmarked caption, show the new number.
|
||||
let seq = changed(doc, seq_results(doc));
|
||||
let mut out = (!seq.is_empty()).then(|| doc.clone());
|
||||
if let Some(d) = out.as_mut() {
|
||||
apply_results(d, seq).ok()?;
|
||||
}
|
||||
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
|
||||
if !styles.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
|
||||
}
|
||||
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
|
||||
if !refs.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
|
||||
fn has_switch(instr: &str, sw: char) -> bool {
|
||||
instr.split('\\').skip(1).any(|s| {
|
||||
|
||||
@@ -92,26 +92,11 @@ pub fn date_picture(instr: &str) -> Option<String> {
|
||||
/// the paragraph's end. `None` when there is no such bookmark.
|
||||
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
bookmark_text_at(doc, name, &start)
|
||||
}
|
||||
|
||||
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
|
||||
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
|
||||
doc.para(start.story, &start.path).is_some_and(|para| {
|
||||
para.object_offsets()
|
||||
.into_iter()
|
||||
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
|
||||
/// looked the document's bookmarks up once.
|
||||
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
|
||||
let para = doc.para(start.story, &start.path)?;
|
||||
let end = para
|
||||
.object_offsets()
|
||||
.into_iter()
|
||||
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
.unwrap_or(para.len());
|
||||
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
}
|
||||
|
||||
@@ -511,353 +511,3 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
/// editor and the document keep every stored result.
|
||||
#[test]
|
||||
fn output_shows_fresh_seq_styleref_and_ref_results() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
|
||||
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Target words"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for (instr, result) in [
|
||||
(r#"SEQ Figure \* ARABIC"#, "7"),
|
||||
(r#"STYLEREF "Heading 1""#, "stale style"),
|
||||
("REF Mark \\h", "stale text"),
|
||||
("NUMWORDS", "999"),
|
||||
(r#"SEQ Table \* ARABIC"#, "9"),
|
||||
] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
// Stale stored results (inserting a field computes its result), the last field locked.
|
||||
let stored = ["7", "stale style", "stale text", "999", "9"];
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
let mut stale = stored.iter();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*result = stale.next().unwrap().to_string();
|
||||
*locked = stale.len() == 0;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.items)
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
|
||||
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
|
||||
let pdf = s.print_pdf(&json!({})).unwrap().0;
|
||||
assert!(pdf.len() > 1000);
|
||||
// Once the fields are current, output copies nothing.
|
||||
update_seq(&mut s).unwrap();
|
||||
update_fields(&mut s).unwrap();
|
||||
assert!(output_fields(&s.doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
|
||||
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
|
||||
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
|
||||
// and a REF to a bookmark in a box shows the box's text as stored.
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
|
||||
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
|
||||
let field = |instr: &str, stored: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
run(stored)
|
||||
)
|
||||
};
|
||||
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
|
||||
let text_box = |inner: String| {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
};
|
||||
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
|
||||
let paras = [
|
||||
seq("A"),
|
||||
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
|
||||
seq("D"),
|
||||
format!("{}{}", seq("E"), text_box(seq("F"))),
|
||||
seq("G"),
|
||||
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
|
||||
];
|
||||
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
|
||||
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
|
||||
// The SEQ and REF results in reading order, text boxes where they are anchored.
|
||||
let mut results = Vec::new();
|
||||
out.objects_in_reading_order(&mut |o| {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
|
||||
}
|
||||
});
|
||||
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
|
||||
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(refs, ["B Fig 9"], "{results:?}");
|
||||
}
|
||||
|
||||
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
|
||||
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
|
||||
/// REF stored as "stale"), rather than Word's missing-bookmark error.
|
||||
#[test]
|
||||
fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
let mark = |name: &str, id: u32| {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
|
||||
};
|
||||
let field = |name: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
|
||||
let refs: String = names.iter().map(|n| field(n)).collect();
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
|
||||
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
|
||||
let footnotes =
|
||||
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
|
||||
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
|
||||
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
|
||||
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
|
||||
);
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let files = [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document),
|
||||
("word/header1.xml", header),
|
||||
("word/footer1.xml", footer),
|
||||
("word/footnotes.xml", footnotes),
|
||||
("word/endnotes.xml", endnotes),
|
||||
("word/comments.xml", comments),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in &files {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
|
||||
let mut results = Vec::new();
|
||||
for path in out.para_paths(StoryRef::Body) {
|
||||
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
results.push(result.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
#[test]
|
||||
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
|
||||
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
|
||||
}
|
||||
|
||||
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
|
||||
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
|
||||
/// text; a plain REF to the same bookmark is filled in.
|
||||
#[test]
|
||||
fn output_keeps_paragraph_number_references_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
|
||||
for instr in instrs {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "2.1".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the plain REF changes");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
|
||||
}
|
||||
|
||||
/// The results of the fields in the paragraph at `path` of `d`.
|
||||
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
|
||||
d.para(StoryRef::Body, path)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
|
||||
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = stale.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
last
|
||||
}
|
||||
|
||||
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
|
||||
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
|
||||
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
|
||||
/// rather than show the first paragraph's part alone.
|
||||
#[test]
|
||||
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
|
||||
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
|
||||
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF Span \\h", "REF Short \\h"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = stale_last_paragraph(&mut s, "stored");
|
||||
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
|
||||
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
|
||||
}
|
||||
|
||||
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
|
||||
/// in output and after F9: the numbers are filled in before the style references read them.
|
||||
#[test]
|
||||
fn a_style_reference_reads_the_headings_new_sequence_number() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
|
||||
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
|
||||
// Stale stored results: the heading's number 9, the reference "stale".
|
||||
let heading = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "9".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let last = stale_last_paragraph(&mut s, "stale");
|
||||
let out = output_fields(&s.doc).expect("the fields change");
|
||||
assert_eq!(results_in(&out, &heading), ["1"]);
|
||||
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
|
||||
update_seq(&mut s).unwrap();
|
||||
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
|
||||
}
|
||||
|
||||
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
|
||||
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
|
||||
/// `bookmark_text` (F9's lookup) gives, or Word's error.
|
||||
#[test]
|
||||
fn output_gives_every_reference_its_bookmarks_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
|
||||
for (text, name) in [("First target", "A"), ("Second target", "B")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let targets = ["A", "B", "A", "Missing", "A"];
|
||||
for t in targets {
|
||||
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
apply(s, &id)?;
|
||||
@@ -131,7 +131,7 @@ fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
@@ -189,7 +189,7 @@ pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = (*s.doc.styles).clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
@@ -231,7 +231,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
Arc::make_mut(&mut s.doc.styles).styles.retain(|x| x.id != id);
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
@@ -101,8 +100,8 @@ fn update_styles(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// What [`update_styles`] does to a document: all of `src`'s styles and its defaults.
|
||||
pub fn apply_template_styles(doc: &mut Document, src: &Document) -> Value {
|
||||
Arc::make_mut(&mut doc.styles).default_chr = src.styles.default_chr.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_para = src.styles.default_para.clone();
|
||||
doc.styles.default_chr = src.styles.default_chr.clone();
|
||||
doc.styles.default_para = src.styles.default_para.clone();
|
||||
let ids: Vec<String> = src.styles.styles.iter().map(|st| st.id.clone()).collect();
|
||||
copy_styles(doc, src, &ids)
|
||||
}
|
||||
@@ -173,7 +172,7 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if s.doc.styles.styles.iter().any(|o| o.id != st.id && o.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||||
}
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.styles).get_mut(&st.id) {
|
||||
if let Some(x) = s.doc.styles.get_mut(&st.id) {
|
||||
x.name = name.clone();
|
||||
}
|
||||
touch_all(s);
|
||||
@@ -347,7 +346,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
};
|
||||
let builtin = doc.styles.get(&target).is_some_and(|d| d.builtin) || st.builtin;
|
||||
Arc::make_mut(&mut doc.styles).upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
doc.styles.upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
map.insert(st.id.clone(), target.clone());
|
||||
order.push((st.id.clone(), target));
|
||||
}
|
||||
@@ -367,7 +366,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
n
|
||||
});
|
||||
if let Some(d) = Arc::make_mut(&mut doc.styles).get_mut(&target) {
|
||||
if let Some(d) = doc.styles.get_mut(&target) {
|
||||
d.based_on = based_on;
|
||||
d.next = next;
|
||||
d.linked = linked;
|
||||
@@ -390,8 +389,8 @@ fn import_num(doc: &mut Document, src: &Document, num: u32, map: &HashMap<String
|
||||
for l in &mut levels {
|
||||
l.style = l.style.as_deref().and_then(|s| map.get(s).cloned().or_else(|| doc.styles.get(s).map(|x| x.id.clone())));
|
||||
}
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
Arc::make_mut(&mut doc.numbering).nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
doc.numbering.abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
doc.numbering.nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
done.insert(num, new_num);
|
||||
Some(new_num)
|
||||
}
|
||||
@@ -406,17 +405,17 @@ mod tests {
|
||||
/// "Box" (missing in the document) and a numbered "Steps" style.
|
||||
fn template() -> Document {
|
||||
let mut t = Document::new();
|
||||
if let Some(h) = Arc::make_mut(&mut t.styles).get_mut("Heading1") {
|
||||
if let Some(h) = t.styles.get_mut("Heading1") {
|
||||
h.chr.size = Some(31.0);
|
||||
}
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
t.styles.upsert(Style {
|
||||
id: "Box".into(),
|
||||
name: "Box".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: Some("Normal".into()),
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
t.styles.upsert(Style {
|
||||
id: "Callout".into(),
|
||||
name: "Callout".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -424,8 +423,8 @@ mod tests {
|
||||
chr: CharProps { italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let num = Arc::make_mut(&mut t.numbering).add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
let num = t.numbering.add_list(ListKind::Numbered);
|
||||
t.styles.upsert(Style {
|
||||
id: "Steps".into(),
|
||||
name: "Steps".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
|
||||
@@ -3,7 +3,6 @@ use super::{
|
||||
Block, Border, BorderStyle, CmdError, CmdResult, ListKind, NumRef, OBJ, Path, Pos, Selection, Session, StoryRef, Table, Value, delete_selection,
|
||||
p, sel_result, split_para, type_text_effect,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes).
|
||||
fn autoformat(s: &mut Session, text: &str) -> Option<String> {
|
||||
@@ -165,7 +164,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
|
||||
let num = match found {
|
||||
Some(n) => n,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.delete(0, f.off)?;
|
||||
@@ -316,11 +315,10 @@ pub(super) fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style), with
|
||||
// no outline level: resolving drops the body-text level 9 and above.
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none()
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
@@ -359,8 +357,14 @@ pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result
|
||||
crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text) {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &prev, &before.props)?;
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
@@ -394,8 +398,14 @@ pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Resu
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if let Some(before) = before {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &at.path, &before)?;
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
|
||||
|
||||
/// Options › Language › Set as default: only the saved default proofing language (what future
|
||||
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
|
||||
/// document, the selection, the undo history and the modified flag are left alone.
|
||||
pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let t = p::str(v, "lang").ok_or_else(|| CmdError::Params("`lang` is required".into()))?;
|
||||
let l = wordcraft_proof::lang::normalize_tag(t)
|
||||
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?;
|
||||
s.prefs.default_language = Some(l.clone());
|
||||
Ok(json!({"preferred": l}))
|
||||
}
|
||||
|
||||
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
|
||||
/// or (`default`) the document's default and the saved language for future blank documents;
|
||||
/// `detect` is the per-user detection preference.
|
||||
@@ -30,9 +19,9 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some(l.clone());
|
||||
s.doc.styles.default_chr.lang = Some(l.clone());
|
||||
// A Normal style that names its own language follows the new default.
|
||||
if let Some(n) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "Normal")
|
||||
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
|
||||
&& n.chr.lang.is_some()
|
||||
{
|
||||
n.chr.lang = Some(l);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::actions::{
|
||||
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
|
||||
};
|
||||
use super::actions::{customize_keyboard, customize_ribbon, hf_nav, language, macros, quick_parts, search_commands, select_similar};
|
||||
use super::autocorrect::autocorrect_options;
|
||||
use super::compare::{COMPARE_PARAMS, compare};
|
||||
use super::inspect::{accessibility, inspect_doc};
|
||||
@@ -8,7 +6,6 @@ use super::{
|
||||
Block, CharProps, CmdError, CommandSpec, InlineObject, NumRef, Paragraph, Selection, StoryRef, Value, delete_selection, json, p, para_block,
|
||||
sel_result,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
|
||||
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
|
||||
@@ -300,8 +297,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(|x| x.props.numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == new) {
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
|
||||
num.start_overrides = vec![(n.level, value)];
|
||||
}
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|q| *q >= f.path).collect();
|
||||
@@ -324,9 +321,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
|
||||
let text = p::str(v, "text").unwrap_or("%1.").to_string();
|
||||
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
|
||||
let id = Arc::make_mut(&mut s.doc.numbering).try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
|
||||
&& let Some(abs) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(l) = abs.levels.first_mut()
|
||||
{
|
||||
l.format = fmt;
|
||||
@@ -342,9 +339,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
|
||||
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
|
||||
),
|
||||
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
|
||||
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
|
||||
.pure(),
|
||||
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
|
||||
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
|
||||
s.relayout();
|
||||
|
||||
@@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||
|
||||
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
if read && !s.view.read_mode {
|
||||
// Width, size and spacing reset on each entry. Page Color follows the saved preference.
|
||||
// Reading controls are session-only, and reset on each entry to the view.
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = s.prefs.read_page_color;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -256,7 +256,6 @@ fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
s.prefs.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let pdf = wordcraft_pdf::PdfOptions {
|
||||
include_markup: opts.markup,
|
||||
comments: opts.markup && opts.comments,
|
||||
@@ -49,8 +46,6 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
|
||||
/// markup area with the comments in balloons (page images always show tracked changes).
|
||||
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
|
||||
+9
-333
@@ -143,9 +143,6 @@ pub fn set_backend(backend: Option<Arc<dyn Backend>>) {
|
||||
let dir = backend.as_ref().and_then(|b| b.packs_dir());
|
||||
*BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend;
|
||||
set_index(None);
|
||||
FETCHED.store(false, Ordering::Relaxed);
|
||||
AUTO_REFRESHED.store(false, Ordering::Relaxed);
|
||||
WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
jobs().errors.clear();
|
||||
wordcraft_proof::packs::set_packs_dir(dir);
|
||||
}
|
||||
@@ -222,8 +219,6 @@ pub fn refresh(cancel: &Cancel) -> Result<Vec<Pack>, String> {
|
||||
}
|
||||
let index = clean_index(b.fetch_index(cancel)?);
|
||||
set_index(Some(index.clone()));
|
||||
FETCHED.store(true, Ordering::Relaxed);
|
||||
install_wanted(&index);
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
@@ -580,50 +575,15 @@ pub fn licence_texts() -> Vec<(String, String)> {
|
||||
/// About › Language data's title for SCOWL's notices.
|
||||
pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)";
|
||||
|
||||
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter
|
||||
/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A
|
||||
/// variant after the region is also tried without the region, before the region alone
|
||||
/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None`
|
||||
/// for English and tags with no pack.
|
||||
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language
|
||||
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
|
||||
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
|
||||
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
|
||||
}
|
||||
|
||||
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
|
||||
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
|
||||
/// [`pack_for_tag`] all use it.
|
||||
pub fn is_region(subtag: &str) -> bool {
|
||||
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
|
||||
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
|
||||
/// The language itself (the first subtag) is never a region.
|
||||
pub fn without_region(tag: &str) -> String {
|
||||
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
|
||||
}
|
||||
|
||||
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
|
||||
fn pack_candidates(tag: &str) -> Vec<String> {
|
||||
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
|
||||
let tag: String = tag.trim().chars().take(64).collect::<String>().replace('_', "-").to_ascii_lowercase();
|
||||
let parts: Vec<&str> = tag.split('-').collect();
|
||||
let base = parts.first().copied().unwrap_or("");
|
||||
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
|
||||
let base = tag.split('-').next().unwrap_or("");
|
||||
if base.is_empty() || base == "en" {
|
||||
return Vec::new();
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for n in (1..=parts.len()).rev() {
|
||||
let prefix = parts.get(..n).unwrap_or_default().join("-");
|
||||
// The prefix, then without its region: the variant's pack before the region alone.
|
||||
let stripped = without_region(&prefix);
|
||||
for c in [prefix, stripped] {
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
|
||||
}
|
||||
|
||||
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
|
||||
@@ -711,33 +671,8 @@ fn has_pack_for_tag(tag: &str) -> bool {
|
||||
PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some())
|
||||
}
|
||||
|
||||
/// The language new text at the caret gets (its typing language, resolved through the styles
|
||||
/// and the document default), unless it is marked Do not check spelling or grammar: choosing a
|
||||
/// language with nothing selected asks for its pack before anything is typed in it.
|
||||
pub fn caret_language(s: &Session) -> Option<String> {
|
||||
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
|
||||
let rc = s.doc.styles.resolve_char(style, &s.typing_props());
|
||||
if rc.no_proof {
|
||||
return None;
|
||||
}
|
||||
Some(rc.lang.unwrap_or_else(|| "en-US".into()))
|
||||
}
|
||||
|
||||
/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then
|
||||
/// the caret's typing language ([`caret_language`]).
|
||||
pub fn wanted_languages(s: &Session) -> Vec<String> {
|
||||
let mut out = document_languages(s);
|
||||
if let Some(tag) = caret_language(s)
|
||||
&& !out.contains(&tag)
|
||||
{
|
||||
out.push(tag);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The packs the document's text (or the caret's typing language) asks for that are available
|
||||
/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one
|
||||
/// proofing can't use.
|
||||
/// The packs the document's text asks for that are available and not installed: what the
|
||||
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
|
||||
pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
let packs = available();
|
||||
if packs.is_empty() {
|
||||
@@ -745,7 +680,7 @@ pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
}
|
||||
let installed = installed();
|
||||
let mut out: Vec<Pack> = Vec::new();
|
||||
for tag in wanted_languages(s) {
|
||||
for tag in document_languages(s) {
|
||||
if proofable(&tag)
|
||||
&& let Some(p) = pack_for_tag(&tag, &packs)
|
||||
&& !installed.iter().any(|i| i.locale == p.locale)
|
||||
@@ -757,265 +692,6 @@ pub fn missing(s: &Session) -> Vec<Pack> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Where a language's proofing comes from (Options › Language › Your languages).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Proofing {
|
||||
/// English: the embedded dictionaries.
|
||||
BuiltIn,
|
||||
/// An installed Language pack.
|
||||
Installed,
|
||||
/// A Hunspell dictionary in the person's own dictionaries folder.
|
||||
OwnDictionary,
|
||||
/// A Hunspell dictionary the system has.
|
||||
SystemDictionary,
|
||||
/// No dictionary yet; a Language pack can be installed.
|
||||
Install,
|
||||
/// Grammar and hyphenation rules built in; a Language pack adds spelling.
|
||||
GrammarBuiltIn,
|
||||
/// No dictionary and no pack for it.
|
||||
NotAvailable,
|
||||
}
|
||||
|
||||
/// A row of Options › Language › Your languages.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct LanguageRow {
|
||||
/// The tags the default proofing language or the open document use for this language (the
|
||||
/// first is what Set as default sets); empty for a language listed for its installed pack.
|
||||
pub tags: Vec<String>,
|
||||
/// The pack locale: the installed pack, else the one the index offers.
|
||||
pub pack: Option<String>,
|
||||
/// Is the pack installed?
|
||||
pub installed: bool,
|
||||
/// Bytes of the offered pack (0 when the index has none).
|
||||
pub size: u64,
|
||||
pub proofing: Proofing,
|
||||
/// Is this the default proofing language?
|
||||
pub default: bool,
|
||||
}
|
||||
|
||||
impl LanguageRow {
|
||||
/// Is `tag` (the insertion point's language) one of this row's languages?
|
||||
pub fn is_tag(&self, tag: &str) -> bool {
|
||||
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk.
|
||||
fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option<String> {
|
||||
pack_candidates(tag).into_iter().find(|c| has(c))
|
||||
}
|
||||
|
||||
/// [`your_languages_with`] against the dictionaries on this computer.
|
||||
pub fn your_languages(s: &Session) -> Vec<LanguageRow> {
|
||||
let folder = s.prefs.dictionaries_folder.clone();
|
||||
your_languages_with(s, folder.as_deref(), &dictionary_file)
|
||||
}
|
||||
|
||||
/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's
|
||||
/// own, or a pack language's), if any.
|
||||
fn dictionary_file(tag: &str) -> Option<PathBuf> {
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(tag));
|
||||
if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) {
|
||||
return f;
|
||||
}
|
||||
wordcraft_proof::pack_dictionary_file(lang)
|
||||
}
|
||||
|
||||
/// Options › Language › Your languages (unsorted; the window names and sorts them): each English
|
||||
/// variant in use (the default proofing language, American English and any in the open document), every language
|
||||
/// with an installed Language pack, and every proofing language of the open document. One row per
|
||||
/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder;
|
||||
/// `dictionary` finds the `.dic` file proofing reads for a tag.
|
||||
pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option<PathBuf>) -> Vec<LanguageRow> {
|
||||
let index = available();
|
||||
let installed = installed();
|
||||
let default_tag = s
|
||||
.prefs
|
||||
.default_language
|
||||
.clone()
|
||||
.or_else(|| s.editing_lang.clone())
|
||||
.or_else(|| s.doc.styles.default_chr.lang.clone())
|
||||
.unwrap_or_else(|| "en-US".to_string());
|
||||
// American English stays listed whatever the default is, so it can always be set back.
|
||||
let mut tags = vec![default_tag.clone(), "en-US".to_string()];
|
||||
tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||||
for t in document_languages(s) {
|
||||
if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) {
|
||||
tags.push(t);
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<LanguageRow> = Vec::new();
|
||||
let mut add = |tag: Option<&str>, locale: Option<String>| {
|
||||
let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english());
|
||||
// English variants stay apart (en-GB and en-US are two rows); a pack's tags share one.
|
||||
let existing = rows.iter().position(|r| match (&locale, &r.pack) {
|
||||
(Some(l), Some(p)) if !english => l == p,
|
||||
_ => tag.is_some_and(|t| r.is_tag(t)),
|
||||
});
|
||||
let i = existing.unwrap_or_else(|| {
|
||||
rows.push(LanguageRow {
|
||||
tags: Vec::new(),
|
||||
pack: locale.clone(),
|
||||
installed: false,
|
||||
size: 0,
|
||||
proofing: Proofing::NotAvailable,
|
||||
default: false,
|
||||
});
|
||||
rows.len() - 1
|
||||
});
|
||||
if let (Some(t), Some(row)) = (tag, rows.get_mut(i))
|
||||
&& !row.is_tag(t)
|
||||
{
|
||||
row.tags.push(t.to_string());
|
||||
}
|
||||
};
|
||||
for t in &tags {
|
||||
if !proofable(t) {
|
||||
continue;
|
||||
}
|
||||
// The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with
|
||||
// `ca` installed, Valencian text is still the `ca-valencia` pack's row.
|
||||
let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l));
|
||||
add(Some(t), locale);
|
||||
}
|
||||
for i in &installed {
|
||||
add(None, Some(i.locale.clone()));
|
||||
}
|
||||
for row in &mut rows {
|
||||
let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l));
|
||||
let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l));
|
||||
row.installed = installed_here;
|
||||
row.size = offered.map_or(0, |p| p.size);
|
||||
row.default = row.is_tag(&default_tag);
|
||||
let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default();
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
|
||||
let file = if lang.is_english() { None } else { dictionary(&tag) };
|
||||
let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim())));
|
||||
row.proofing = if lang.is_english() {
|
||||
Proofing::BuiltIn
|
||||
} else if own {
|
||||
Proofing::OwnDictionary
|
||||
} else if installed_here {
|
||||
Proofing::Installed
|
||||
} else if file.is_some() {
|
||||
Proofing::SystemDictionary
|
||||
} else if offered.is_some() {
|
||||
if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn }
|
||||
} else {
|
||||
Proofing::NotAvailable
|
||||
};
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The index was fetched this session (since the backend was set): what it lists is then the
|
||||
/// whole answer, even when it lists nothing.
|
||||
static FETCHED: AtomicBool = AtomicBool::new(false);
|
||||
/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried.
|
||||
static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false);
|
||||
/// Languages (document tags) whose pack the person asked for before the list was known
|
||||
/// ([`start_get`]): installed once a fetch lists one.
|
||||
static WANTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Whether the index was fetched since the backend was set.
|
||||
pub fn fetched() -> bool {
|
||||
FETCHED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The languages the document (or the caret) asks for while there is no list of packs yet (a
|
||||
/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary
|
||||
/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The
|
||||
/// bar offers to get them: the list is fetched first, and the language then installed if it has
|
||||
/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers).
|
||||
pub fn unlisted(s: &Session) -> Vec<String> {
|
||||
if backend().is_none() || fetched() || available_len() > 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let installed = installed();
|
||||
let mut seen: Vec<wordcraft_proof::ProofLang> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for tag in wanted_languages(s) {
|
||||
let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
|
||||
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
|
||||
if base.is_empty()
|
||||
|| base == "en"
|
||||
|| !proofable(&tag)
|
||||
|| wordcraft_proof::missing_dictionary(Some(&tag)).is_none()
|
||||
|| seen.contains(&lang)
|
||||
|| installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seen.push(lang);
|
||||
out.push(tag);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their
|
||||
/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it
|
||||
/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on.
|
||||
/// Returns whether it is still running.
|
||||
pub fn start_get(tag: &str) -> Result<bool, String> {
|
||||
let tag = tag.trim();
|
||||
if tag.is_empty() || tag.len() > MAX_LOCALE_LEN {
|
||||
return Err(format!("`{tag}` isn't a language tag"));
|
||||
}
|
||||
if let Some(p) = pack_for_tag(tag, &available()) {
|
||||
return start_install(&p.locale);
|
||||
}
|
||||
let b = backend().ok_or_else(no_store)?;
|
||||
if !b.network_on() {
|
||||
// Never queued: a later fetch mustn't install what was refused.
|
||||
return Err(network_off());
|
||||
}
|
||||
let queued = {
|
||||
let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES;
|
||||
if new {
|
||||
w.push(tag.to_string());
|
||||
}
|
||||
new
|
||||
};
|
||||
let r = start_refresh();
|
||||
if r.is_err() && queued {
|
||||
WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// After a fetch: install the packs asked for before the list was known ([`start_get`]).
|
||||
fn install_wanted(index: &[Pack]) {
|
||||
let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
for tag in wanted {
|
||||
let Some(p) = pack_for_tag(&tag, index) else { continue };
|
||||
if is_installed(&p.locale) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = start_install(&p.locale) {
|
||||
set_error(&p.locale, Some(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]:
|
||||
/// the window), with downloading already on and no list (none cached, or one cached for an older
|
||||
/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the
|
||||
/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The
|
||||
/// window asks every frame: after the first time this reads nothing (the downloading setting is
|
||||
/// a file). Returns whether a fetch was started.
|
||||
pub fn refresh_if_stale() -> bool {
|
||||
let Some(b) = backend() else { return false };
|
||||
if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
if !b.network_on() || fetched() || fetching() || available_len() > 0 {
|
||||
return false;
|
||||
}
|
||||
start_refresh().is_ok()
|
||||
}
|
||||
|
||||
/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`.
|
||||
#[derive(Default)]
|
||||
pub struct FixedBackend {
|
||||
|
||||
@@ -35,7 +35,7 @@ pub struct Prefs {
|
||||
/// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system.
|
||||
pub region: Option<String>,
|
||||
/// A folder searched for Hunspell dictionaries before the system locations (File › Options ›
|
||||
/// Language, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// Proofing, #1063): an app started from Finder or the Dock doesn't see
|
||||
/// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only.
|
||||
pub dictionaries_folder: Option<String>,
|
||||
/// The user templates folder File › New lists personal templates from and Save as Word
|
||||
@@ -49,9 +49,6 @@ pub struct Prefs {
|
||||
/// Check spelling as you type (File › Options › Proofing). The live value is
|
||||
/// `ViewState::proofing`; this is the copy that is saved and restored (#1792).
|
||||
pub check_spelling: bool,
|
||||
/// Read Mode's Page Color. The live value is `ViewState::read_page_color`.
|
||||
#[serde(deserialize_with = "lenient_read_page_color")]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub ignore_uppercase: bool,
|
||||
pub ignore_numbers: bool,
|
||||
pub ignore_internet: bool,
|
||||
@@ -77,7 +74,6 @@ impl Default for Prefs {
|
||||
marks: Default::default(),
|
||||
mark_grammar: true,
|
||||
check_spelling: true,
|
||||
read_page_color: Default::default(),
|
||||
ignore_uppercase: true,
|
||||
ignore_numbers: true,
|
||||
ignore_internet: true,
|
||||
@@ -104,30 +100,3 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||
}
|
||||
|
||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Prefs;
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
|
||||
#[test]
|
||||
fn read_page_color_loads_from_old_and_damaged_prefs() {
|
||||
let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap();
|
||||
assert_eq!(old.read_page_color, ReadPageColor::None);
|
||||
assert!(!old.check_spelling);
|
||||
for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] {
|
||||
let prefs: Prefs = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(prefs.read_page_color, ReadPageColor::None);
|
||||
assert!(!prefs.check_spelling);
|
||||
}
|
||||
let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap();
|
||||
assert_eq!(saved.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
//!
|
||||
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
|
||||
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
|
||||
//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex
|
||||
//! are handled by the host.
|
||||
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
|
||||
//!
|
||||
//! Page ranges and copies come from users and control channels, so they are bounded: a range
|
||||
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
|
||||
@@ -11,7 +10,6 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_layout::DocLayout;
|
||||
pub use wordcraft_pdf::imposition::Imposition;
|
||||
|
||||
use crate::{CmdError, Session, p};
|
||||
|
||||
@@ -148,47 +146,6 @@ pub fn paper(v: &Value) -> Result<Option<(&'static str, f32, f32)>, CmdError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the physical sheet layout without touching document page setup.
|
||||
pub fn imposition(v: &Value) -> Result<wordcraft_pdf::imposition::Imposition, CmdError> {
|
||||
match v.get("pagesPerSheet") {
|
||||
None => Ok(Imposition::Single),
|
||||
Some(Value::Number(n)) => match n.as_u64() {
|
||||
Some(1) => Ok(Imposition::Single),
|
||||
Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
},
|
||||
Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy.
|
||||
fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result<Option<(f32, f32)>, CmdError> {
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
if imposition(v)? == Imposition::Single {
|
||||
return Ok(paper);
|
||||
}
|
||||
let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h));
|
||||
let Some((w, h)) = paper.or(own) else { return Ok(paper) };
|
||||
match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") {
|
||||
"auto" => Ok(paper),
|
||||
"portrait" => Ok(Some((w.min(h), w.max(h)))),
|
||||
"landscape" => Ok(Some((w.max(h), w.min(h)))),
|
||||
_ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan physical sheet sides for one copy, using the same selection and geometry as printing.
|
||||
pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result<Vec<wordcraft_pdf::imposition::Sheet>, CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let pages = plan(&single, lay.pages.len(), current)?.pages;
|
||||
imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The pages the caret (or selection) is on, in the output layout: (first, last).
|
||||
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
|
||||
@@ -212,26 +169,14 @@ impl Session {
|
||||
self.print_pdf_with(v, false)
|
||||
}
|
||||
|
||||
/// Render a shared sheet plan for a saved PDF or an annotation-free printer job.
|
||||
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||||
let markup = crate::cmd::file::export_options(self, v);
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
let doc = out.as_ref().unwrap_or(&self.doc);
|
||||
let lay = self.export_layout_of(doc, markup.markup);
|
||||
let lay = self.export_layout_with(markup.markup);
|
||||
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let paper = imposed_paper(v, &lay, &single_plan.pages)?;
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
let opts = wordcraft_pdf::PdfOptions {
|
||||
pages: Some(single_plan.pages),
|
||||
imposition: imposition(v)?,
|
||||
sheet_copies: plan.copies,
|
||||
sheet_collate: p::bool(v, "collate").unwrap_or(true),
|
||||
pages: Some(plan.pages.clone()),
|
||||
// Repeated pages would repeat the structure tree's entries too.
|
||||
tagged: plan.copies <= 1,
|
||||
include_markup: markup.markup,
|
||||
@@ -240,7 +185,7 @@ impl Session {
|
||||
links,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
Ok((bytes, plan))
|
||||
}
|
||||
}
|
||||
@@ -395,31 +340,3 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod imposition_tests {
|
||||
use super::*;
|
||||
/// Sheet printing leaves document bytes, dirty state, selection and undo history untouched.
|
||||
#[test]
|
||||
fn print_imposition_is_pure_and_rejects_hostile_modes() {
|
||||
let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five"));
|
||||
let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap());
|
||||
for (mode, sides) in
|
||||
[(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)]
|
||||
{
|
||||
let params = json!({"pagesPerSheet":mode, "copies":2});
|
||||
let lay = s.export_layout();
|
||||
assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides);
|
||||
let (bytes, plan) = s.print_pdf(¶ms).unwrap();
|
||||
assert_eq!(plan.copies, 2);
|
||||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
|
||||
}
|
||||
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
|
||||
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
|
||||
}
|
||||
for params in [json!(null), json!([]), json!(false)] {
|
||||
assert!(s.print_pdf(¶ms).is_err());
|
||||
}
|
||||
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Built-in sample documents and templates (original text, generated in code).
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block};
|
||||
@@ -51,10 +50,10 @@ pub fn sample_document() -> Document {
|
||||
quote.props.space_before = Some(12.0);
|
||||
b.push(Block::Para(quote));
|
||||
b.push(Block::Para(styled("What we agree on", "Heading2")));
|
||||
let mut num = (*d.numbering).clone();
|
||||
let mut num = d.numbering.clone();
|
||||
let bullets = num.add_list(ListKind::Bullet);
|
||||
let steps = num.add_list(ListKind::Numbered);
|
||||
d.numbering = Arc::new(num);
|
||||
d.numbering = num;
|
||||
for (lead, rest) in [
|
||||
("Clean as you go. ", "Leave a table the way you would like to find it."),
|
||||
("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."),
|
||||
|
||||
@@ -385,7 +385,6 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -460,7 +459,6 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -161,15 +161,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
|
||||
/// area with the comments in balloons. Fields Word fills in for output show their fresh
|
||||
/// results ([`crate::cmd::references::output_fields`]).
|
||||
/// area with the comments in balloons.
|
||||
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
|
||||
}
|
||||
|
||||
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
|
||||
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
|
||||
let (fn_name, fn_path) = self.resolved_filenames();
|
||||
let opts = LayoutOptions {
|
||||
view: ViewMode::Print,
|
||||
@@ -179,7 +172,7 @@ impl Session {
|
||||
filepath: Some(Arc::from(fn_path.as_str())),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
|
||||
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
|
||||
}
|
||||
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
@@ -206,7 +199,7 @@ impl Session {
|
||||
self.editing_lang = tag;
|
||||
let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty();
|
||||
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
|
||||
Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
// Not an edit: the document is as untouched as it was.
|
||||
if self.made_rev == self.rev.wrapping_sub(1) {
|
||||
|
||||
@@ -31,7 +31,6 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,7 +135,6 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
+36
-245
@@ -4,7 +4,6 @@ use wordcraft_proof::cs::install_dictionary;
|
||||
mod czech_test_dict;
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -2571,22 +2570,19 @@ fn select_extend_from_a_table_paragraph_selects_the_document() {
|
||||
assert_eq!(b, s.doc.end_of(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot)
|
||||
/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text
|
||||
/// as paragraphs.
|
||||
/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table
|
||||
/// in the deepest cell says so; a table pasted there brings its text as paragraphs.
|
||||
#[test]
|
||||
fn tables_nest_only_as_deep_as_a_json_save_reads() {
|
||||
fn tables_nest_at_most_24_deep() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err();
|
||||
assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}");
|
||||
assert!(e.to_string().contains("24 deep"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "edit.paste", json!({"text": "b", "html": "<table><tr><td>b</td></tr></table>"}));
|
||||
let cell = s.sel.focus.path.clone();
|
||||
@@ -2595,44 +2591,6 @@ fn tables_nest_only_as_deep_as_a_json_save_reads() {
|
||||
assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there");
|
||||
}
|
||||
|
||||
/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads
|
||||
/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and
|
||||
/// gradient text effects changed with Track Changes on (the change keeps the old effects too).
|
||||
#[test]
|
||||
fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "word"}));
|
||||
run(&mut s, "select.text", json!({"text": "word"}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": true}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": false}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "in box"}));
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_ne!(s.sel.focus.story, StoryRef::Body);
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
// Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did.
|
||||
let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap();
|
||||
let gradient = |f: &Option<wordcraft_doc::fill::Fill>| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_)));
|
||||
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}");
|
||||
assert!(
|
||||
para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())),
|
||||
"{para:?}"
|
||||
);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
}
|
||||
|
||||
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
|
||||
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
|
||||
#[test]
|
||||
@@ -4398,7 +4356,7 @@ fn toc_page_numbers_reach_the_right_margin() {
|
||||
let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line";
|
||||
let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix"));
|
||||
{
|
||||
let toc2 = Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap();
|
||||
let toc2 = d.styles.get_mut("TOC2").unwrap();
|
||||
toc2.para.tabs = Some(vec![
|
||||
TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None },
|
||||
TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot },
|
||||
@@ -4535,7 +4493,7 @@ fn toc_entries_carry_heading_numbers() {
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let num = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for b in s.doc.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
|
||||
&& p.props.style.as_deref() == Some("Heading1")
|
||||
@@ -4635,41 +4593,13 @@ fn hostile_params_never_panic() {
|
||||
for j in &junk {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
|
||||
let r = s.run(spec.id, j);
|
||||
assert!(!caught_panic(®, &r), "{} panicked on {j}: {r:?}", spec.id);
|
||||
let _ = s.run(spec.id, j);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
|
||||
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
|
||||
/// those words.
|
||||
fn caught_panic(reg: &crate::Registry, r: &Result<serde_json::Value, crate::CmdError>) -> bool {
|
||||
matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_panic_flags_a_command_that_panics() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure();
|
||||
// Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207).
|
||||
let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure();
|
||||
s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer]));
|
||||
let boom = s.run("test.boom", &json!({}));
|
||||
assert!(caught_panic(&s.registry, &boom));
|
||||
let nested = s.run("test.outer", &json!({}));
|
||||
assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}");
|
||||
assert!(caught_panic(&s.registry, &nested));
|
||||
assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into()))));
|
||||
// A command's own error can quote what it was given.
|
||||
let mut full = Session::new(crate::sample::sample_document());
|
||||
let echoed = full.run("developer.control", &json!({"control": "x panicked: y"}));
|
||||
assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}");
|
||||
assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_structure() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
@@ -4836,10 +4766,10 @@ fn commands_after_the_largest_ids_take_free_ones() {
|
||||
s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() });
|
||||
s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() });
|
||||
}
|
||||
Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].abstract_id = u32::MAX;
|
||||
s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
s.doc.numbering.abstracts[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].abstract_id = u32::MAX;
|
||||
let before = s.doc.clone();
|
||||
assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1}));
|
||||
assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2}));
|
||||
@@ -4936,7 +4866,7 @@ fn word_count_includes_text_boxes_by_default() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
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))];
|
||||
@@ -5909,111 +5839,6 @@ fn modify_style_takes_hex_colours_like_format_color() {
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_share_the_style_sheet_and_lists_until_a_command_changes_them() {
|
||||
// Every undo step copied the whole style sheet and list definitions: kilobytes a style, so
|
||||
// typing into a document with hundreds of styles cost megabytes a step.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("One two three."));
|
||||
for _ in 0..5 {
|
||||
run(&mut s, "text.insert", json!({"text": "word "}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_labels().len(), 5, "five undo steps");
|
||||
assert!(Arc::strong_count(&s.doc.styles) >= 6, "the document and each step hold one style sheet");
|
||||
assert!(Arc::strong_count(&s.doc.numbering) >= 6, "and one set of list definitions");
|
||||
// A command that changes a style copies the sheet once; undo puts the shared one back.
|
||||
let sheet = Arc::clone(&s.doc.styles);
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"bold": true}}));
|
||||
assert!(!Arc::ptr_eq(&sheet, &s.doc.styles));
|
||||
assert_ne!(*sheet, *s.doc.styles, "the change is in the new copy only");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&sheet, &s.doc.styles), "undo restores the step's own sheet");
|
||||
// Lists likewise.
|
||||
let lists = Arc::clone(&s.doc.numbering);
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert!(!Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
assert!(s.doc.numbering.nums.len() > lists.nums.len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
}
|
||||
|
||||
/// The same at scale: 1,000 edits of a document with 600 styles and 60 lists of nine levels. The
|
||||
/// history keeps the newest `MAX_UNDO` steps and drops the oldest. Its steps hold one style sheet
|
||||
/// for each style change they span, however many steps there are, and one set of lists. A sheet
|
||||
/// that only dropped steps held is freed.
|
||||
#[test]
|
||||
fn a_thousand_edits_of_a_large_document_keep_the_newest_steps_and_share_its_styles() {
|
||||
use crate::doc_state::MAX_UNDO;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Weak;
|
||||
|
||||
let body: Vec<String> = (0..200).map(|i| format!("Paragraph {i} of the body, with a few words in it.")).collect();
|
||||
let mut doc = wordcraft_doc::Document::from_text(&body.join("\n"));
|
||||
let sheet = Arc::make_mut(&mut doc.styles);
|
||||
let normal = sheet.get("Normal").cloned().unwrap();
|
||||
for i in 0..600 {
|
||||
let (id, name) = (format!("Custom{i}"), format!("Custom {i}"));
|
||||
sheet.upsert(wordcraft_doc::Style {
|
||||
id,
|
||||
name,
|
||||
based_on: Some("Normal".into()),
|
||||
builtin: false,
|
||||
default: false,
|
||||
quick: false,
|
||||
..normal.clone()
|
||||
});
|
||||
}
|
||||
for _ in 0..60 {
|
||||
Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Legal);
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
|
||||
// An edit types a word, except every hundredth, which changes a style: one undo step each.
|
||||
// After each edit: the text, and the sheet and lists the document then had.
|
||||
let edits = 1000;
|
||||
let mut after = Vec::with_capacity(edits);
|
||||
let mut probe = Weak::new();
|
||||
for i in 0..edits {
|
||||
if i % 100 == 50 {
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"size": 10 + i / 100}}));
|
||||
} else {
|
||||
run(&mut s, "text.insert", json!({"text": format!("w{i} ")}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
after.push((text(&s), Arc::downgrade(&s.doc.styles), Arc::downgrade(&s.doc.numbering)));
|
||||
// Edit 50's sheet is replaced at edit 150, so the step taken before edit 150 is the last
|
||||
// to hold it. That step is the oldest one kept until edit 650 pushes it out.
|
||||
match i {
|
||||
50 => probe = Arc::downgrade(&s.doc.styles),
|
||||
649 => assert!(probe.upgrade().is_some(), "a kept step still holds edit 50's sheet"),
|
||||
650 => assert!(probe.upgrade().is_none(), "dropping the last step that held edit 50's sheet frees it"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.undo_steps_so_far(), edits as u64, "one undo step an edit");
|
||||
assert_eq!(s.undo_depth(), MAX_UNDO);
|
||||
|
||||
// Undo goes back through the newest steps, newest first. Each restores the text and the very
|
||||
// sheet and lists the document had after that edit: the steps share them, not copies.
|
||||
let (mut sheets, mut lists) = (HashSet::new(), HashSet::new());
|
||||
for k in 0..=MAX_UNDO {
|
||||
let (txt, sheet, numbering) = &after[edits - 1 - k];
|
||||
assert_eq!(&text(&s), txt, "after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.styles, &sheet.upgrade().unwrap()), "the sheet after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.numbering, &numbering.upgrade().unwrap()), "the lists after {k} undos");
|
||||
sheets.insert(Arc::as_ptr(&s.doc.styles));
|
||||
lists.insert(Arc::as_ptr(&s.doc.numbering));
|
||||
if k < MAX_UNDO {
|
||||
assert!(s.undo(), "undo {}", k + 1);
|
||||
}
|
||||
}
|
||||
assert!(!s.undo(), "the oldest 500 steps were dropped: edits 0 to 499 can't be undone");
|
||||
// The 501 states, the document's and the kept steps', hold six sheets (edit 450's, and those
|
||||
// of the changes at 550 to 950) and one set of lists.
|
||||
assert_eq!(sheets.len(), 6);
|
||||
assert_eq!(lists.len(), 1);
|
||||
}
|
||||
|
||||
/// #865: the other colours in CharProps and ParaProps (`underlineColor`, `shading`, a border's
|
||||
/// `color`) take `RRGGBB` / `#RRGGBB` like `para.borders` and `table.shading`, and still `[r, g, b]`.
|
||||
#[test]
|
||||
@@ -6735,7 +6560,7 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, starts at None and is kept between documents and runs as a preference.
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
@@ -6746,46 +6571,18 @@ fn reading_page_color_is_a_transient_view_setting() {
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
// The color stays for the next entry to Read Mode, while the other reading controls reset.
|
||||
run(&mut s, "view.readTextSize", json!({"value": 150}));
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.view.read_zoom, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_page_color_follows_saved_prefs_and_new_documents() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
s.prefs.read_page_color = ReadPageColor::Sepia;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
let first = s.document_id();
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
s.activate(first).unwrap();
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.open", json!({"path": "reading-color.docx", "data": ""}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
s.set_document(wordcraft_doc::Document::new());
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
@@ -7291,7 +7088,7 @@ fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
|
||||
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
|
||||
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
|
||||
@@ -7436,7 +7233,7 @@ fn delete_table_style_falls_back_and_undoes() {
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
@@ -8048,7 +7845,7 @@ fn list_buttons_follow_the_paragraphs_own_level() {
|
||||
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
|
||||
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
|
||||
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
|
||||
Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
run(&mut s, "para.listLevel", json!({"level": 1}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
@@ -8841,7 +8638,7 @@ const PORTUGUESE_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD F\nTRY aeiousrntmdlcpvgqbzfhjxçãõáéíóúâêô\nMAP 2\nMAP aáã\nMAP eé\nREP 1\nREP seje seja\n";
|
||||
const PORTUGUESE_TEST_DIC: &str = "11\nsaudade\ncoração\ne\nele\ndisse\nnão\né\nque\nseja\nfrequencia/F\nmuito\n";
|
||||
|
||||
/// #1063: File › Options › Language › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// #1063: File › Options › Proofing › Dictionaries folder. A folder of Hunspell files is saved,
|
||||
/// searched first and reported per language; a missing folder or a relative path is refused and
|
||||
/// changes nothing. The folder holds the same test dictionaries the other tests install, so tests
|
||||
/// running meanwhile see the same words; it stays set for the rest of the run for that reason.
|
||||
@@ -9435,7 +9232,7 @@ fn default_proofing_language_survives_a_restart() {
|
||||
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
|
||||
|
||||
let mut english = wordcraft_doc::Document::from_text("English document");
|
||||
Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into());
|
||||
english.styles.default_chr.lang = Some("en-GB".into());
|
||||
let bytes = crate::io::save_bytes("english.docx", &english).unwrap();
|
||||
let data = crate::cmd::insert::base64_encode(&bytes);
|
||||
run(&mut second, "file.open", json!({"path": "english.docx", "data": data}));
|
||||
@@ -10078,7 +9875,7 @@ fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
|
||||
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!({}));
|
||||
@@ -10741,14 +10538,8 @@ fn inspect_document_hidden_text_in_every_story_and_from_styles() {
|
||||
run(&mut s, "insert.footer", json!({"text": "f"}));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
let hidden = CharProps { hidden: Some(true), ..Default::default() };
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideC".into(),
|
||||
name: "Hide C".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: hidden.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() });
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "HideP".into(),
|
||||
name: "Hide P".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -10859,7 +10650,7 @@ fn inspect_document_removes_text_hidden_by_document_defaults() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Paragraph, para_block};
|
||||
let mut s = s();
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.hidden = Some(true);
|
||||
s.doc.styles.default_chr.hidden = Some(true);
|
||||
s.doc.body = vec![
|
||||
para_block(Paragraph::with_text("hide-default", CharProps::default())),
|
||||
para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })),
|
||||
@@ -10902,7 +10693,7 @@ fn inspect_document_keeps_content_control_markers() {
|
||||
fn inspect_document_lists_tables_whose_style_hides_text() {
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
|
||||
let mut s = s();
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "HideHeader".into(),
|
||||
name: "Hide Header".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -11496,8 +11287,8 @@ fn toc_keeps_direct_number_tabs() {
|
||||
d.last_section.page_w = 220.0;
|
||||
d.last_section.margin_left = 20.0;
|
||||
d.last_section.margin_right = 20.0;
|
||||
Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.props.style = Some("TOCHeading".into());
|
||||
@@ -11799,11 +11590,11 @@ fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
|
||||
body.chr.size = Some(20.0);
|
||||
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
|
||||
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(body.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).latent_styles = Some("<w:latentStyles/>".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0);
|
||||
s.doc.styles.styles.push(body.clone());
|
||||
s.doc.styles.styles.push(grid.clone());
|
||||
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
|
||||
s.doc.styles.default_chr.lang = Some("de-DE".into());
|
||||
s.doc.styles.default_chr.size = Some(11.0);
|
||||
run(&mut s, "design.styleSet", json!({ "name": name }));
|
||||
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
|
||||
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
|
||||
@@ -11873,8 +11664,8 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
|
||||
let mut s = s();
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0);
|
||||
s.doc.styles.default_chr.size = None;
|
||||
s.doc.styles.default_chr.size_cs = Some(10.0);
|
||||
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
|
||||
ch.chr.size = Some(20.0);
|
||||
ch.chr.size_cs = Some(22.0);
|
||||
@@ -11883,7 +11674,7 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
parts.header_chr.size = Some(14.0);
|
||||
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
|
||||
tb.table = Some(parts);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]);
|
||||
s.doc.styles.styles.extend([ch, tb]);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
let st = &s.doc.styles;
|
||||
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
|
||||
@@ -12045,7 +11836,7 @@ fn alt_chunks_open_in_place_as_word_brings_them_in() {
|
||||
fn text_from_file_brings_the_files_pictures() {
|
||||
let dir = scratch_dir("text-from-file-pictures");
|
||||
let mut src = Session::new(wordcraft_doc::Document::new());
|
||||
let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
// A list item, then a paragraph (a single paragraph would take the caret's formatting).
|
||||
src.doc.split_paragraph(&Pos::body(0, 0)).unwrap();
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
|
||||
@@ -12060,7 +11851,7 @@ fn text_from_file_brings_the_files_pictures() {
|
||||
let mut s = s();
|
||||
// A picture and a list of our own first, so the file's key and list id would point at ours.
|
||||
let ours = s.doc.add_media(vec![9, 9, 9], "png");
|
||||
let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let shown: Vec<String> = s
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open.
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -119,9 +118,9 @@ fn font_commands_override_inherited_complex_script_properties_explicitly() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
|
||||
let mut s = session("שלום ABC", true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true);
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.default_chr.bold_cs = Some(true);
|
||||
s.doc.styles.default_chr.italic_cs = Some(true);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": false}));
|
||||
run(&mut s, "format.italic", json!({"value": false}));
|
||||
|
||||
@@ -59,16 +59,15 @@ fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
|
||||
let mut s = mixed();
|
||||
let styles = std::sync::Arc::make_mut(&mut s.doc.styles);
|
||||
styles.default_chr.size_cs = Some(30.0);
|
||||
styles.upsert(Style {
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Large".into(),
|
||||
name: "Large".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
styles.upsert(Style {
|
||||
s.doc.styles.upsert(Style {
|
||||
id: "Small".into(),
|
||||
name: "Small".into(),
|
||||
kind: StyleKind::Character,
|
||||
|
||||
@@ -192,280 +192,3 @@ fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() {
|
||||
let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT));
|
||||
assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})");
|
||||
}
|
||||
|
||||
/// `font.missing`'s fonts, as "font>shownAs".
|
||||
fn missing_list(s: &mut Session) -> Vec<String> {
|
||||
let m = s.run("font.missing", &json!({})).unwrap();
|
||||
m["missing"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect();
|
||||
format!("{}>{}", f["font"].as_str().unwrap(), shown.join(","))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The missing fonts of `doc` as laid out.
|
||||
fn missing_in(doc: wordcraft_doc::Document) -> Vec<crate::cmd::font::MissingFont> {
|
||||
let mut s = Session::new(doc);
|
||||
let layout = s.layout();
|
||||
crate::cmd::font::missing_fonts(&s.doc, &layout)
|
||||
}
|
||||
|
||||
/// A paragraph of `text` set in `font`.
|
||||
fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph {
|
||||
wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() })
|
||||
}
|
||||
|
||||
/// A document of these paragraphs.
|
||||
fn doc_of(paras: Vec<wordcraft_doc::Paragraph>) -> wordcraft_doc::Document {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() {
|
||||
// A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute.
|
||||
const MISSING: &str = "Wordcraft Nowhere Face";
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed");
|
||||
assert_eq!(missing_list(&mut s), Vec::<String>::new());
|
||||
|
||||
s.run("select.text", &json!({"text": "Missing"})).unwrap();
|
||||
s.run("format.font", &json!({"name": MISSING})).unwrap();
|
||||
let st = s.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["font"], MISSING);
|
||||
let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string();
|
||||
assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}");
|
||||
assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once");
|
||||
|
||||
// A mixed selection has no single font, so nothing to flag in the font box.
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_only_a_style_names_is_not_reported_missing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Unused Look"})).unwrap();
|
||||
s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
|
||||
// Creating a style applies it, as in Word: put the text back in Normal.
|
||||
s.run("para.style", &json!({"style": "Normal"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
|
||||
assert_eq!(missing_list(&mut s), Vec::<String>::new(), "no text uses the style");
|
||||
// Text in the style, with no font of its own, is.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Used Look"})).unwrap();
|
||||
s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
|
||||
s.run("para.style", &json!({"style": "Used Look"})).unwrap();
|
||||
let list = missing_list(&mut s);
|
||||
assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_are_what_layout_drew_in_shown_text() {
|
||||
let mut doc = doc_of(vec![
|
||||
para_in("one", "Wordcraft Nowhere Face"),
|
||||
para_in("two", "wordcraft nowhere face"),
|
||||
para_in("three", "JetBrains Mono"),
|
||||
// Spaces, a picture's placeholder and zero-width characters show no font.
|
||||
para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"),
|
||||
]);
|
||||
// Hidden text isn't laid out.
|
||||
doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(
|
||||
"secret",
|
||||
wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() },
|
||||
)));
|
||||
// A text box story nothing shows any more (deleted, not yet pruned).
|
||||
doc.parts.insert(
|
||||
77,
|
||||
wordcraft_doc::Part {
|
||||
kind: wordcraft_doc::PartKind::TextBox,
|
||||
blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))],
|
||||
body: Default::default(),
|
||||
},
|
||||
);
|
||||
let names: Vec<String> = missing_in(doc).into_iter().map(|f| f.font).collect();
|
||||
assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}");
|
||||
assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_twins_are_listed_but_flagged_compatible() {
|
||||
// A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's
|
||||
// the substitute; a made-up family's substitute is never its twin.
|
||||
let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")]));
|
||||
let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed");
|
||||
assert!(!nowhere.metric_compatible, "{nowhere:?}");
|
||||
match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") {
|
||||
// Arial is installed: not listed.
|
||||
None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"),
|
||||
Some(sub) => {
|
||||
let arial = m.iter().find(|f| f.font == "Arial").expect("listed");
|
||||
// An emoji drawn in a fallback face doesn't change the verdict on the substitute.
|
||||
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_roman_font_drawn_in_cambria_is_reported() {
|
||||
// Word draws a missing font the font table classes as roman in Cambria (#1613): installed or
|
||||
// not, Cambria isn't the font the document asks for.
|
||||
const ROMAN: &str = "Wordcraft Roman Gone";
|
||||
let mut doc = doc_of(vec![para_in("Text", ROMAN)]);
|
||||
doc.settings.roman_fonts = vec![ROMAN.into()];
|
||||
let m = missing_in(doc.clone());
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [ROMAN], "{m:?}");
|
||||
assert!(!m[0].metric_compatible, "{m:?}");
|
||||
// The font box names the face layout draws, not the generic substitute.
|
||||
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone();
|
||||
assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria));
|
||||
let mut s = Session::new(doc);
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_east_asian_font_is_reported_for_cjk_text_only() {
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() };
|
||||
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())]));
|
||||
assert_eq!(m.len(), 1, "{m:?}");
|
||||
assert_eq!(m[0].font, "Wordcraft Nowhere Mincho");
|
||||
assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}");
|
||||
// Without CJK text the East Asian font draws nothing.
|
||||
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "Wingdings"})).unwrap();
|
||||
// Not installed, so it's drawn in another face, but on purpose: nothing to report.
|
||||
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string());
|
||||
}
|
||||
let layout = s.layout();
|
||||
assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_from_command_search_say_so_in_the_status_line() {
|
||||
let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")]));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status);
|
||||
// Thousands of missing fonts: a few named, the rest counted.
|
||||
let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect()));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_font_name_is_kept_short() {
|
||||
let name = format!("Wordcraft {}", "é".repeat(100_000));
|
||||
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_fonts_drawn_as_unicode_are_not_reported() {
|
||||
// Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents.
|
||||
let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")]));
|
||||
assert!(m.is_empty(), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() {
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() };
|
||||
let names = |text: &str| -> Vec<String> {
|
||||
missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect()
|
||||
};
|
||||
assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font");
|
||||
assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]);
|
||||
// Without an East Asian font the CJK text asks for the Latin one.
|
||||
let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() };
|
||||
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)]));
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), ["Wordcraft Latin Gone"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_font_named_for_complex_script_text_is_not_reported() {
|
||||
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() };
|
||||
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_number_of_missing_fonts_stays_bounded() {
|
||||
let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect();
|
||||
let m = missing_in(doc_of(paras));
|
||||
assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_key_matches_the_listed_name_however_long_or_padded() {
|
||||
// Leading spaces and more than 256 characters: layout keeps 256, the list trims them.
|
||||
let name = format!(" Wordcraft {}", "x".repeat(400));
|
||||
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font));
|
||||
// The status line shortens such names.
|
||||
let mut s = Session::new(doc_of(vec![para_in("Text", &name)]));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.chars().count() < 80, "{}", s.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_characters_that_draw_count() {
|
||||
use wordcraft_layout::para::draws_something;
|
||||
for c in ['a', '日', '€', '\u{1F600}'] {
|
||||
assert!(draws_something(c), "{c:?}");
|
||||
}
|
||||
for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] {
|
||||
assert!(!draws_something(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_font_name_is_not_a_missing_font() {
|
||||
// A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name.
|
||||
assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() {
|
||||
// Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is
|
||||
// installed; one of Arial's twins (Helvetica on a Mac) measures like it.
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "Arial ")]));
|
||||
let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return };
|
||||
let drawn = arial.shown_as.first().expect("drawn in a face");
|
||||
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
// MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an
|
||||
// East Asian font's text goes to the run's font or a fallback face, as the list says.
|
||||
const MINCHO: &str = "Wordcraft Mincho Gone";
|
||||
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() };
|
||||
let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]);
|
||||
doc.settings.roman_fonts = vec![MINCHO.into()];
|
||||
let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed");
|
||||
let m = missing_in(doc.clone());
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [MINCHO], "{m:?}");
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! results are what Word produces for the same keys.
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{BorderStyle, VertAlign};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -484,7 +483,7 @@ fn autocorrect_replacement_list_follows_the_texts_language() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
|
||||
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.
|
||||
@@ -834,28 +833,6 @@ fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() {
|
||||
use wordcraft_doc::props::Align;
|
||||
// Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting
|
||||
// change is Ben's and holds Ana's centered paragraph without a border.
|
||||
let mut s = typed("---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
|
||||
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
|
||||
assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false)));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
|
||||
let mut s = typed("+--+------+");
|
||||
@@ -1088,7 +1065,7 @@ fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// A list level past the last one, from a style or set directly, goes to the last level.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
@@ -1192,10 +1169,6 @@ fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
let s = with_heading_styles("One two three four five\n\n");
|
||||
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
|
||||
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() {
|
||||
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
|
||||
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
|
||||
let s = with_heading_styles(keys);
|
||||
@@ -1213,26 +1186,15 @@ fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() {
|
||||
// A list item numbered by the Normal style is not body text either.
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") {
|
||||
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = q.doc.styles.get_mut("Normal") {
|
||||
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
}
|
||||
typ(&mut q, "Item\n\n");
|
||||
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
|
||||
// A line with an outline level stays; the body-text level (9 and above) is no outline level.
|
||||
for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] {
|
||||
let mut q = with_heading_styles("Title\n");
|
||||
q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level);
|
||||
typ(&mut q, "\n");
|
||||
assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_are_off_by_default() {
|
||||
// Off by default.
|
||||
let s = typed("Introduction\n\n");
|
||||
let h = |t: &str| (t.to_string(), None);
|
||||
assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]);
|
||||
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1249,18 +1211,6 @@ fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
// Enter in the empty paragraph turns a zero-tab line into Heading 1.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
|
||||
|
||||
// With Track Changes on, the tab is marked deleted and the style change is a formatting
|
||||
// revision, so Reject All brings the line back as it was.
|
||||
let mut s = with_heading_styles("\tMethods\n");
|
||||
@@ -1294,64 +1244,6 @@ fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() {
|
||||
use wordcraft_doc::props::Align;
|
||||
// Ana centers a zero-tab line; Ben's Enter makes it Heading 1.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben"));
|
||||
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
|
||||
|
||||
// Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is
|
||||
// removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
|
||||
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
|
||||
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
|
||||
// Rejecting it brings back Ana's centered Normal line.
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
|
||||
// The author's own earlier change keeps the formatting before it.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
|
||||
assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None));
|
||||
assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
let mut s = s();
|
||||
@@ -1363,25 +1255,6 @@ fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() {
|
||||
// Read only, with an editable region from the end of "Title" to the start of "End.": Enter
|
||||
// twice in the region breaks the paragraph and leaves the protected line Normal text, and a
|
||||
// border line typed there goes under the paragraph above it, inside the region.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd."));
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) };
|
||||
run(&mut s, "review.restrict", json!({"exception": "add"}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||
run(&mut s, "select.text", json!({"text": "X"}));
|
||||
typ(&mut s, "\n\n");
|
||||
assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s));
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
typ(&mut s, "---\n");
|
||||
assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some());
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_bullets() {
|
||||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||||
@@ -2163,7 +2036,7 @@ fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
|
||||
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
|
||||
// A style that sets left-to-right wins.
|
||||
let mut doc = Session::new(wordcraft_doc::Document::new());
|
||||
if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") {
|
||||
if let Some(st) = doc.doc.styles.get_mut("Normal") {
|
||||
st.para.bidi = Some(false);
|
||||
}
|
||||
typ(&mut doc, "سلام");
|
||||
@@ -3170,15 +3043,15 @@ fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -
|
||||
use wordcraft_doc::props::NumRef;
|
||||
use wordcraft_doc::styles::Style;
|
||||
let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta");
|
||||
let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap();
|
||||
Arc::make_mut(&mut doc.styles).styles.push(Style {
|
||||
let num = doc.numbering.try_add_list(kind).unwrap();
|
||||
doc.styles.styles.push(Style {
|
||||
id: "ListBase".into(),
|
||||
name: "Original List".into(),
|
||||
para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
if chain {
|
||||
Arc::make_mut(&mut doc.styles).styles.push(Style {
|
||||
doc.styles.styles.push(Style {
|
||||
id: "ListChild".into(),
|
||||
name: "Original Derived".into(),
|
||||
based_on: Some("ListBase".into()),
|
||||
@@ -3254,13 +3127,13 @@ fn list_toggle_uses_effective_level_and_override() {
|
||||
for explicit_kind in [false, true] {
|
||||
let mut s = style_inherited_list(kind, true);
|
||||
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
|
||||
let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
|
||||
let num = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
|
||||
num.level = 1;
|
||||
let num_id = num.num;
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
if override_level {
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone();
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
|
||||
s.doc.numbering.abstracts[0].levels[1] = levels_for(opposite)[1].clone();
|
||||
s.doc.numbering.nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) };
|
||||
@@ -3289,7 +3162,7 @@ fn list_paragraph_style_survives_inherited_toggle_off() {
|
||||
use wordcraft_doc::styles::Style;
|
||||
for kind in [ListKind::Bullet, ListKind::Numbered] {
|
||||
let mut s = style_inherited_list(kind, false);
|
||||
*Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
|
||||
*s.doc.styles.styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
|
||||
id: "ListParagraph".into(),
|
||||
name: "Original formatted list".into(),
|
||||
based_on: Some("ListBase".into()),
|
||||
@@ -3344,7 +3217,7 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
|
||||
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
|
||||
let mut s = style_inherited_list(kind, false);
|
||||
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
let num = s.doc.numbering.nums[0].id;
|
||||
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 });
|
||||
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default();
|
||||
@@ -3400,7 +3273,7 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
|
||||
@@ -3462,7 +3335,7 @@ fn highlight_toggle_checks_past_the_resolved_run_limit() {
|
||||
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
|
||||
.collect();
|
||||
if inherited {
|
||||
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow);
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
} else {
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
@@ -3522,7 +3395,7 @@ fn highlight_toggle_checks_whole_cell_marks() {
|
||||
Pos::new(StoryRef::Body, path, 0)
|
||||
};
|
||||
if inherited {
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow);
|
||||
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
for row in 0..2 {
|
||||
for col in 0..2 {
|
||||
@@ -3574,7 +3447,7 @@ fn highlight_toggle_checks_ordinary_paragraph_marks() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
for text in ["AB", "AB\n\nAB", "\n"] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(text);
|
||||
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue);
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Blue);
|
||||
let paths = doc.para_paths(StoryRef::Body);
|
||||
for path in &paths {
|
||||
let p = doc.para_mut(StoryRef::Body, path).unwrap();
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct ViewState {
|
||||
pub read_width: f32,
|
||||
#[serde(skip, default = "default_read_height")]
|
||||
pub read_height: f32,
|
||||
/// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs.
|
||||
/// Reader-only layout controls; they are neither document formatting nor saved UI prefs.
|
||||
/// `None` chooses responsive column width and keeps the document's line spacing.
|
||||
#[serde(skip)]
|
||||
pub read_column_width: Option<f32>,
|
||||
@@ -69,7 +69,7 @@ pub struct ViewState {
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value.
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on.
|
||||
#[serde(skip)]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub dark_mode: bool,
|
||||
|
||||
@@ -56,9 +56,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), String> {
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// Arabic interface faces are small, so the web build keeps them too.
|
||||
let small_ui_face = scripts.split(',').any(|s| matches!(s.trim(), "Arab" | "Geor"));
|
||||
if wasm && !small_ui_face {
|
||||
if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
+1
-41
@@ -24,10 +24,7 @@ pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
SystemUiFont, is_cjk, system_arabic_ui_font, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk,
|
||||
ui_uncovered_cjk,
|
||||
};
|
||||
pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk};
|
||||
|
||||
/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts;
|
||||
/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`).
|
||||
@@ -101,18 +98,6 @@ pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static
|
||||
}
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), in manifest order: the document fallback
|
||||
/// for Arabic text. Empty without craft-fonts.
|
||||
pub fn arabic_fonts() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab"))
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Arabic script (`"Arab"`), Regular styles only: the interface's
|
||||
/// fallback for an Arabic interface and for Arabic file names. Empty without craft-fonts.
|
||||
pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
|
||||
arabic_fonts().filter(|f| f.style == "Regular").collect()
|
||||
}
|
||||
|
||||
/// The bundled serif used when a document asks for nothing better.
|
||||
pub const DEFAULT_FAMILY: &str = "Source Serif 4";
|
||||
|
||||
@@ -529,31 +514,6 @@ mod tests {
|
||||
assert!(fb.family.contains("Mincho"), "{fb:?}");
|
||||
}
|
||||
|
||||
/// Arabic text in a document needs no system font: the craft-fonts Arabic face is the
|
||||
/// fallback (the browser build has no other), and its letters join when shaped.
|
||||
#[test]
|
||||
fn craft_fonts_arabic_face_is_the_document_fallback_for_arabic() {
|
||||
if arabic_fonts().next().is_none() {
|
||||
eprintln!("skipped: built without a craft-fonts Arabic face (set CRAFT_FONTS_DIR to a checkout)");
|
||||
return;
|
||||
}
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
for ch in "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأإةى،؛؟٠١٢٣٤٥٦٧٨٩ًٌٍَُِّْ".chars() {
|
||||
assert!(!latin.covers(ch), "the bundled serif has no Arabic");
|
||||
let fb = db.fallback_for(ch, latin.id()).unwrap_or_else(|| panic!("no fallback for {ch}"));
|
||||
assert!(fb.family.contains("Arabic"), "{ch}: {}", fb.family);
|
||||
}
|
||||
let face = db.fallback_for('ع', latin.id()).unwrap();
|
||||
// Joined forms: the same letter gets a different glyph alone and inside a word.
|
||||
let alone = shape(&face, "ع", &[], |c| c);
|
||||
let joined = shape(&face, "ععع", &[], |c| c);
|
||||
assert_eq!((alone.len(), joined.len()), (1, 3));
|
||||
assert!(joined.iter().all(|g| g.gid != 0));
|
||||
assert!(joined.iter().any(|g| g.gid != alone[0].gid), "letters take their joining forms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_uses_one_face_covering_the_whole_run() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
|
||||
@@ -218,36 +218,6 @@ pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Opti
|
||||
find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans))
|
||||
}
|
||||
|
||||
/// Arabic font files to look for, relative to a font folder, best first.
|
||||
fn arabic_candidates() -> &'static [&'static str] {
|
||||
if cfg!(windows) {
|
||||
&["segoeui.ttf", "tahoma.ttf", "arial.ttf"]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&["SFArabic.ttf", "GeezaPro.ttc", "Supplemental/GeezaPro.ttc", "Supplemental/Arial Unicode.ttf", "Supplemental/Arial.ttf"]
|
||||
} else {
|
||||
&[
|
||||
"noto/NotoSansArabic-Regular.ttf",
|
||||
"truetype/noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto/NotoSansArabic-Regular.ttf",
|
||||
"google-noto-sans-arabic-vf-fonts/NotoSansArabic[wght].ttf",
|
||||
"noto/NotoNaskhArabic-Regular.ttf",
|
||||
"truetype/noto/NotoNaskhArabic-Regular.ttf",
|
||||
"google-noto/NotoNaskhArabic-Regular.ttf",
|
||||
"vazirmatn/Vazirmatn-Regular.ttf",
|
||||
"truetype/dejavu/DejaVuSans.ttf",
|
||||
"dejavu/DejaVuSans.ttf",
|
||||
"TTF/DejaVuSans.ttf",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed font with Arabic letters, for an Arabic interface in a build whose craft-fonts
|
||||
/// input has no Arabic face. Reads at most one font file per candidate; never panics on damaged
|
||||
/// files.
|
||||
pub fn system_arabic_ui_font() -> Option<SystemUiFont> {
|
||||
find_ui_font(&crate::system_font_dirs(), arabic_candidates(), &['ع'], Region::Other)
|
||||
}
|
||||
|
||||
/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font<N>`),
|
||||
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
|
||||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData/PingFang.ttc`
|
||||
@@ -454,9 +424,6 @@ mod tests {
|
||||
// No file maps every needed character: the one mapping the most is used.
|
||||
let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap();
|
||||
assert_eq!(found.family, "Source Sans 3");
|
||||
// The bundled Latin face has no Arabic letters, so it is never taken for an Arabic interface.
|
||||
assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['ع'], Region::Other).is_none());
|
||||
assert!(!arabic_candidates().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,41 +192,6 @@ pub fn charset_base_family(family: &str) -> &str {
|
||||
.map_or(family, str::trim_end)
|
||||
}
|
||||
|
||||
/// Fonts made to measure alike (the same advance widths and line height), so text drawn in one
|
||||
/// wraps and paginates as in the others: Carlito for Calibri, Caladea for Cambria, Liberation and
|
||||
/// Croscore fonts for Arial, Times New Roman and Courier New, the Palatino look-alikes for Palatino
|
||||
/// Linotype and Book Antiqua, and Arial and Helvetica (drawn to Helvetica's widths) for one
|
||||
/// another. The one list of them: a missing font is drawn in its first installed twin
|
||||
/// ([`substitute`]), and text drawn in a twin doesn't change the layout ([`metric_compatible`]).
|
||||
/// Not macOS Times or Courier: their advance widths differ from Times New Roman's and Courier
|
||||
/// New's.
|
||||
const METRIC_TWINS: &[&[&str]] = &[
|
||||
&["Arial", "Liberation Sans", "Helvetica", "Arimo"],
|
||||
&["Times New Roman", "Liberation Serif", "Tinos"],
|
||||
&["Courier New", "Liberation Mono", "Cousine"],
|
||||
&["Calibri", "Carlito"],
|
||||
&["Cambria", "Caladea"],
|
||||
&["Palatino Linotype", "Palatino", "Book Antiqua", "TeX Gyre Pagella", "P052", "URW Palladio L", "Palladio"],
|
||||
];
|
||||
|
||||
/// The fonts that measure like `family` ([`METRIC_TWINS`]), in the order they are tried, without
|
||||
/// `family` itself.
|
||||
fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
let f = family.trim();
|
||||
METRIC_TWINS
|
||||
.iter()
|
||||
.filter(move |g| g.iter().any(|t| t.eq_ignore_ascii_case(f)))
|
||||
.flat_map(|g| g.iter().copied())
|
||||
.filter(move |t| !t.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Whether `substitute` was made to measure like `family` ([`METRIC_TWINS`]), so text drawn in it
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
/// Persian font packs; matched by name only).
|
||||
fn is_persian_naskh(f: &str) -> bool {
|
||||
@@ -378,10 +343,6 @@ fn is_hebrew(f: &str) -> bool {
|
||||
/// letters and digits as well as Thai ones (a Thai face without Latin would leave a mixed run in
|
||||
/// two fonts, #1549), else the first with Thai letters, else the first installed.
|
||||
fn substitute(db: &FontDb, family: &str) -> Option<&'static str> {
|
||||
// A twin draws the text as the font would: Carlito for Calibri, Helvetica for Arial on a Mac.
|
||||
if let Some(twin) = metric_twins(family).find(|t| db.has_family(t)) {
|
||||
return Some(twin);
|
||||
}
|
||||
let mut installed = substitutes(family).iter().copied().filter(|s| db.has_family(s));
|
||||
if !is_thai_family(family) {
|
||||
return installed.next();
|
||||
@@ -516,30 +477,6 @@ mod tests {
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_compatible_substitutes_are_known() {
|
||||
assert!(metric_compatible("Calibri", "Carlito"));
|
||||
assert!(metric_compatible(" arial ", "Liberation Sans"));
|
||||
assert!(metric_compatible("Arial CE", "Helvetica"));
|
||||
assert!(metric_compatible("Book Antiqua", "TeX Gyre Pagella"));
|
||||
assert!(!metric_compatible("Calibri", "Helvetica Neue"));
|
||||
assert!(!metric_compatible("Open Sans", "Carlito"));
|
||||
assert!(!metric_compatible("Times New Roman", "Times"));
|
||||
assert_eq!(metric_twins("arial").collect::<Vec<_>>(), ["Liberation Sans", "Helvetica", "Arimo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_font_is_drawn_in_an_installed_twin() {
|
||||
// The twins that decide the font box's warning are the faces layout draws in.
|
||||
let db = FontDb::global();
|
||||
for family in METRIC_TWINS.iter().flat_map(|g| g.iter()) {
|
||||
let r = resolve(family, false, false);
|
||||
if r.substituted && metric_twins(family).any(|t| db.has_family(t)) {
|
||||
assert!(metric_compatible(family, &r.face.family), "{family} → {}", r.face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_substitutes() {
|
||||
let r = resolve("Definitely Not A Font 123", false, false);
|
||||
|
||||
@@ -56,10 +56,9 @@ pub fn notes(doc: &Document) -> Vec<Note> {
|
||||
let mut open: Vec<u32> = Vec::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let mut objects = p.objects.iter();
|
||||
for c in p.text.chars() {
|
||||
for (off, c) in p.text.char_indices() {
|
||||
if c == OBJ {
|
||||
match objects.next() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::CommentStart { id }) => {
|
||||
if seen.insert(*id) {
|
||||
order.push(*id);
|
||||
@@ -199,6 +198,3 @@ fn text(notes: &[Note]) -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::{Block, CharProps, Comment, Paragraph, PartKind, Run, Table, para_block};
|
||||
|
||||
fn paragraph(text: String, objects: Vec<InlineObject>) -> Paragraph {
|
||||
let len = text.len();
|
||||
Paragraph { text, objects, runs: vec![Run { len, props: CharProps::default() }], ..Paragraph::new() }
|
||||
}
|
||||
|
||||
fn comment(doc: &mut Document, id: u32, author: &str, parent: Option<u32>, text: &str) {
|
||||
let part = doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
doc.comments.insert(id, Comment { author: author.into(), part, parent, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_walk_preserves_comment_quotes_order_and_unpaired_objects() {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, " Ada ", None, "First note");
|
||||
comment(&mut doc, 3, " ", Some(9), "Reply text");
|
||||
comment(&mut doc, 5, "", None, "Second note");
|
||||
comment(&mut doc, 7, "Grace", None, "Unanchored");
|
||||
comment(&mut doc, 12, "Linus", None, "Cell note");
|
||||
doc.body = vec![para_block(paragraph(
|
||||
format!("é漢{OBJ}α {OBJ}β{OBJ} γ{OBJ}{OBJ}"),
|
||||
vec![
|
||||
InlineObject::CommentStart { id: 9 },
|
||||
InlineObject::CommentStart { id: 5 },
|
||||
InlineObject::CommentEnd { id: 9 },
|
||||
InlineObject::CommentEnd { id: 5 },
|
||||
// No marker for this object: it must not anchor the unanchored comment.
|
||||
InlineObject::BookmarkStart { name: "unrelated".into() },
|
||||
InlineObject::CommentStart { id: 7 },
|
||||
],
|
||||
))];
|
||||
// Missing object storage must not replay an earlier comment marker.
|
||||
doc.body.push(para_block(paragraph(format!("{OBJ}{OBJ}tail"), vec![])));
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks =
|
||||
vec![para_block(paragraph(format!("{OBJ}cell{OBJ}"), vec![InlineObject::CommentStart { id: 12 }, InlineObject::CommentEnd { id: 12 }]))];
|
||||
doc.body.push(std::sync::Arc::new(Block::Table(table)));
|
||||
let before = doc.clone();
|
||||
let actual = notes(&doc);
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![
|
||||
Note { author: "Ada".into(), quote: "α β".into(), text: "First note".into(), replies: vec![("Author".into(), "Reply text".into())] },
|
||||
Note { author: "Author".into(), quote: "β γ".into(), text: "Second note".into(), replies: vec![] },
|
||||
Note { author: "Linus".into(), quote: "cell".into(), text: "Cell note".into(), replies: vec![] },
|
||||
Note { author: "Grace".into(), quote: "".into(), text: "Unanchored".into(), replies: vec![] },
|
||||
]
|
||||
);
|
||||
assert_eq!(doc, before, "export discovery must leave the document unchanged");
|
||||
}
|
||||
|
||||
fn dense_markers(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, "Ada", None, "Kept note");
|
||||
let mut objects = Vec::with_capacity(2 * n + 2);
|
||||
for k in 0..n {
|
||||
objects.push(InlineObject::BookmarkStart { name: format!("bookmark{k}") });
|
||||
objects.push(InlineObject::BookmarkEnd { name: format!("bookmark{k}") });
|
||||
}
|
||||
objects.extend([InlineObject::CommentStart { id: 9 }, InlineObject::CommentEnd { id: 9 }]);
|
||||
doc.body = vec![para_block(paragraph(format!("{}{OBJ}é漢 quote{OBJ}", OBJ.to_string().repeat(2 * n)), objects))];
|
||||
doc
|
||||
}
|
||||
|
||||
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
|
||||
// performance evidence; the semantic assertions below still run on every platform.
|
||||
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
|
||||
#[test]
|
||||
fn comment_export_discovery_scales_with_unrelated_markers() {
|
||||
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
|
||||
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
|
||||
assert_eq!(notes(&small), expected);
|
||||
assert_eq!(notes(&big), expected);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(notes(&small));
|
||||
}
|
||||
});
|
||||
let b = cpu_cost(|| {
|
||||
std::hint::black_box(notes(&big));
|
||||
});
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
|
||||
} else {
|
||||
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,5 @@ pub use export::{esc, export, export_flow, export_fragment, inlines_html};
|
||||
use import::parse_in;
|
||||
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
|
||||
|
||||
#[cfg(test)]
|
||||
use css::css_unescape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -46,48 +46,6 @@ pub(super) fn strip_important(v: &str) -> &str {
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
|
||||
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
|
||||
/// form feed count as one newline, as CSS reads them (§3.3).
|
||||
pub(super) fn css_unescape(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
|
||||
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
|
||||
Some('\r') => {
|
||||
it.next();
|
||||
it.next_if_eq(&'\n');
|
||||
}
|
||||
Some(' ' | '\t' | '\n' | '\u{C}') => {
|
||||
it.next();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
o.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
|
||||
hex.extend(it.next());
|
||||
}
|
||||
if hex.is_empty() {
|
||||
// Before a newline it continues the line; at the end it is dropped.
|
||||
match it.peek() {
|
||||
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
|
||||
Some(_) => o.extend(it.next()),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skip_space(&mut it);
|
||||
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
|
||||
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Apply a `style` attribute to formatting / block properties.
|
||||
pub(super) fn apply_style(
|
||||
css: &str,
|
||||
@@ -130,7 +88,7 @@ pub(super) fn apply_style(
|
||||
}
|
||||
}
|
||||
"font-family" => {
|
||||
let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
|
||||
let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string();
|
||||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
|
||||
@@ -19,24 +19,6 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
|
||||
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
|
||||
/// these are written as hex escapes ([`css_unescape`] reads them back).
|
||||
fn css_font_name(name: &str) -> String {
|
||||
let mut o = String::with_capacity(name.len() + 2);
|
||||
o.push('\'');
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'\0' => o.push('\u{FFFD}'),
|
||||
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('\'');
|
||||
o
|
||||
}
|
||||
|
||||
fn align_css(a: Option<Align>) -> Option<&'static str> {
|
||||
match a? {
|
||||
Align::Center => Some("text-align:center"),
|
||||
@@ -110,7 +92,7 @@ fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:{}", esc(&css_font_name(fam))));
|
||||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
|
||||
@@ -314,44 +314,6 @@ fn leading_space_survives_round_trip() {
|
||||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_escapes_read_every_kind_of_newline() {
|
||||
// A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one
|
||||
// of them (CR LF as one) ends the escape.
|
||||
assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE");
|
||||
assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD");
|
||||
assert_eq!(css_unescape("x\\'y\\"), "x'y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_are_written_as_css_strings() {
|
||||
// A font name could end the CSS string or the declaration and add CSS of its own (#3739).
|
||||
let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"];
|
||||
let mut p = Para::default();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() });
|
||||
}
|
||||
let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }));
|
||||
assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}");
|
||||
let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect();
|
||||
assert_eq!(styles.len(), fonts.len(), "{html}");
|
||||
for s in styles {
|
||||
assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}");
|
||||
}
|
||||
// Read back, each run has its font as it was.
|
||||
let back = parse(&html);
|
||||
let runs: Vec<(String, Option<String>)> = back
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None })
|
||||
.collect();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||||
let props = wordcraft_doc::CharProps::default();
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::table::{MAX_COLS, MAX_ROWS};
|
||||
use wordcraft_doc::{Align, Document};
|
||||
|
||||
use crate::html::decode_entity;
|
||||
@@ -22,11 +21,6 @@ mod blocks;
|
||||
mod export;
|
||||
mod inlines;
|
||||
|
||||
/// Table cells a Markdown document makes in all, header rows included (hostile input). A short
|
||||
/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column
|
||||
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
|
||||
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
|
||||
|
||||
pub use blocks::{Refs, import, import_on, parse};
|
||||
pub(crate) use export::escape;
|
||||
pub use export::export;
|
||||
@@ -34,7 +28,5 @@ pub use export::export;
|
||||
use export::{escape_line_start, inlines_md, table_md, write_blocks};
|
||||
pub use inlines::parse_inlines;
|
||||
|
||||
#[cfg(test)]
|
||||
use blocks::split_row;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::inlines::parse_inlines;
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model};
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, Para, model};
|
||||
|
||||
/// Nesting limit for block containers (quotes, list items) and inline links.
|
||||
pub(super) const MAX_NEST: usize = 32;
|
||||
@@ -143,8 +143,7 @@ pub fn parse(text: &str) -> model::Flow {
|
||||
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
|
||||
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
|
||||
let refs = take_definitions(&mut lines);
|
||||
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs);
|
||||
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
@@ -272,69 +271,47 @@ fn list_marker(t: &str) -> Option<Marker> {
|
||||
if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None }
|
||||
}
|
||||
|
||||
/// A table row without its leading and trailing pipes (an escaped last pipe stays).
|
||||
fn row_body(l: &str) -> &str {
|
||||
fn split_row(l: &str) -> Vec<String> {
|
||||
let t = l.trim();
|
||||
let t = t.strip_prefix('|').unwrap_or(t);
|
||||
if let Some(before) = t.strip_suffix('|') {
|
||||
let t = if let Some(before) = t.strip_suffix('|') {
|
||||
let num_bs = before.chars().rev().take_while(|&c| c == '\\').count();
|
||||
if num_bs % 2 == 0 { before } else { t }
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted.
|
||||
pub(super) fn split_row(l: &str, keep: usize) -> (Vec<String>, usize) {
|
||||
};
|
||||
let mut cells = Vec::new();
|
||||
let mut count = 0usize;
|
||||
let mut cur = String::new();
|
||||
let mut chars = row_body(l).chars().peekable();
|
||||
let mut chars = t.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
let full = cells.len() >= keep;
|
||||
match c {
|
||||
'\\' => match chars.peek() {
|
||||
Some(&'|') => {
|
||||
if !full {
|
||||
cur.push('|');
|
||||
}
|
||||
cur.push('|');
|
||||
chars.next();
|
||||
}
|
||||
Some(&'\\') => {
|
||||
if !full {
|
||||
cur.push_str("\\\\");
|
||||
}
|
||||
cur.push('\\');
|
||||
cur.push('\\');
|
||||
chars.next();
|
||||
}
|
||||
_ if !full => cur.push('\\'),
|
||||
_ => {}
|
||||
_ => cur.push('\\'),
|
||||
},
|
||||
'|' => {
|
||||
if !full {
|
||||
cells.push(std::mem::take(&mut cur).trim().to_string());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
_ if !full => cur.push(c),
|
||||
_ => {}
|
||||
'|' => cells.push(std::mem::take(&mut cur).trim().to_string()),
|
||||
_ => cur.push(c),
|
||||
}
|
||||
}
|
||||
if cells.len() < keep {
|
||||
cells.push(cur.trim().to_string());
|
||||
}
|
||||
(cells, count + 1)
|
||||
cells.push(cur.trim().to_string());
|
||||
cells
|
||||
}
|
||||
|
||||
/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every
|
||||
/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row.
|
||||
fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
|
||||
fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
|
||||
if !l.contains('-') {
|
||||
return None;
|
||||
}
|
||||
let cells = split_row(l);
|
||||
let mut out = Vec::new();
|
||||
let mut count = 0usize;
|
||||
// A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one.
|
||||
for c in row_body(l).split('|') {
|
||||
for c in cells {
|
||||
let c = c.trim();
|
||||
let left = c.starts_with(':');
|
||||
let right = c.ends_with(':');
|
||||
@@ -342,17 +319,14 @@ fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
|
||||
if core.is_empty() || !core.chars().all(|x| x == '-') {
|
||||
return None;
|
||||
}
|
||||
if out.len() < keep {
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
Some((out, count))
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Does this line start a block that interrupts a paragraph?
|
||||
@@ -412,8 +386,7 @@ fn code_para(line: &str) -> FBlock {
|
||||
}
|
||||
|
||||
/// Parse block structure. `level` is the list nesting level for lists found here.
|
||||
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<FBlock> {
|
||||
let mut out = Vec::new();
|
||||
let mut para: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -487,52 +460,32 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
|
||||
}
|
||||
if t.starts_with('>') {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
|
||||
parse_quote(lines, &mut i, depth, level, refs, &mut out);
|
||||
continue;
|
||||
}
|
||||
if para.is_empty()
|
||||
&& t.contains('|')
|
||||
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
|
||||
&& let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l))
|
||||
{
|
||||
let (head, head_width) = split_row(t, MAX_COLS);
|
||||
if head_width == width {
|
||||
let head = split_row(t);
|
||||
if head.len() == aligns.len() {
|
||||
let mut table = FTable::default();
|
||||
let cell = |s: &str, header: bool, a: Option<Align>| {
|
||||
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
|
||||
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
|
||||
};
|
||||
// The document keeps no more columns or rows than this, nor cells past its budget,
|
||||
// so those aren't built; their lines are still the table's.
|
||||
let cols = aligns.len();
|
||||
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
|
||||
let mut cut = head_width > cols;
|
||||
if fits(0) {
|
||||
budget.set(budget.get() - cols);
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
i += 2;
|
||||
while let Some(l) = lines.get(i) {
|
||||
if is_blank(l) || !l.contains('|') || interrupts(l) {
|
||||
break;
|
||||
}
|
||||
if !table.rows.is_empty() && fits(table.rows.len()) {
|
||||
budget.set(budget.get() - cols);
|
||||
let (mut cells, _) = split_row(l, cols);
|
||||
cells.resize(cols, String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
let mut cells = split_row(l);
|
||||
cells.resize(aligns.len(), String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
i += 1;
|
||||
}
|
||||
if cut {
|
||||
model::dropped(model::Dropped::TableCells);
|
||||
}
|
||||
if !table.rows.is_empty() {
|
||||
out.push(FBlock::Table(table));
|
||||
}
|
||||
out.push(FBlock::Table(table));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -540,7 +493,7 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
|
||||
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
|
||||
{
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs);
|
||||
continue;
|
||||
}
|
||||
para.push(line.clone());
|
||||
@@ -568,7 +521,7 @@ fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
|
||||
out.extend(code.into_iter().map(code_para));
|
||||
}
|
||||
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, out: &mut Vec<FBlock>) {
|
||||
let mut inner = Vec::new();
|
||||
while let Some(line) = lines.get(*i) {
|
||||
let trimmed = line.trim_start_matches([' ', '\t']);
|
||||
@@ -588,7 +541,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
|
||||
out.push(FBlock::Para(p));
|
||||
return;
|
||||
}
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs) {
|
||||
if let FBlock::Para(p) = &mut block
|
||||
&& p.kind == Kind::Normal
|
||||
{
|
||||
@@ -626,7 +579,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
|
||||
}
|
||||
|
||||
/// Parse a list starting at `lines[i]`; returns the index after it.
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs) -> usize {
|
||||
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
|
||||
while let Some(line) = lines.get(i) {
|
||||
let ind = indent(line);
|
||||
@@ -673,7 +626,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
p.push_text(&item.join(" "), &Fmt::default());
|
||||
vec![FBlock::Para(p)]
|
||||
} else {
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs)
|
||||
};
|
||||
let mut placed = false;
|
||||
if inner.is_empty() {
|
||||
|
||||
@@ -356,67 +356,6 @@ fn three_column_table_with_distinctive_text() {
|
||||
assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cells_past_the_document_limits_are_not_built() {
|
||||
// Every body row was padded to the header's width before any cap (#3701).
|
||||
let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&wide);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(t.rows.iter().all(|r| r.len() == MAX_COLS));
|
||||
assert_eq!(t.rows[2][0].text(), "b");
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the columns left out are logged");
|
||||
// Rows past the limit still belong to the table, so the text after it reads as before.
|
||||
let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&tall);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), MAX_ROWS);
|
||||
let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") };
|
||||
assert_eq!(p.text(), "after");
|
||||
assert_eq!(flow.blocks.len(), 2);
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the rows left out are logged");
|
||||
}
|
||||
|
||||
/// A short row is padded to its header's width, so a small file of one-cell rows under wide
|
||||
/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell
|
||||
/// budget: rows past it are left out and logged, and their lines make no paragraphs.
|
||||
#[test]
|
||||
fn tables_share_one_cell_budget() {
|
||||
let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|"));
|
||||
let fits = crate::markdown::MAX_TABLE_CELLS / MAX_COLS;
|
||||
let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&src);
|
||||
let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect();
|
||||
let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum();
|
||||
assert!(cells <= crate::markdown::MAX_TABLE_CELLS, "{cells} cells");
|
||||
assert_eq!(tables.iter().map(|t| t.rows.len()).collect::<Vec<_>>(), [fits], "the second table is past the budget");
|
||||
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["after"]);
|
||||
assert!(crate::model::Dropped::TableCells.reported());
|
||||
// Within the budget a table is whole and nothing is logged.
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&format!("{}| 1 | 2 |\n", header(2)));
|
||||
let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) };
|
||||
assert_eq!(t.rows.len(), 2);
|
||||
assert!(!crate::model::Dropped::TableCells.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_table_still_checks_every_header_and_delimiter_cell() {
|
||||
// Only the first 63 cells are kept, but every one is counted and checked.
|
||||
let head = "|a".repeat(100) + "|\n";
|
||||
let delim = |bad: Option<usize>| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::<String>() + "|\n";
|
||||
let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_)));
|
||||
assert!(is_table(format!("{head}{}| b |\n", delim(None))));
|
||||
assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones");
|
||||
assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells");
|
||||
let (cells, n) = split_row(r"| a | b \| c | d |", 2);
|
||||
assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_table_cells_containing_escaped_pipes() {
|
||||
let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n";
|
||||
|
||||
@@ -290,7 +290,7 @@ impl<'a> Builder<'a> {
|
||||
level
|
||||
})
|
||||
.collect();
|
||||
let numbering = Arc::make_mut(&mut self.doc.numbering);
|
||||
let numbering = &mut self.doc.numbering;
|
||||
let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels, ..Default::default() });
|
||||
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
@@ -425,7 +425,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document
|
||||
doc.numbering = Default::default();
|
||||
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
|
||||
if uses_code {
|
||||
Arc::make_mut(&mut doc.styles).upsert(code_style());
|
||||
doc.styles.upsert(code_style());
|
||||
}
|
||||
let body = Builder::new(&mut doc).blocks(&flow.blocks, 0);
|
||||
doc.body = body;
|
||||
|
||||
@@ -54,10 +54,6 @@ pub(crate) enum Dropped {
|
||||
RunStrings,
|
||||
/// A style name cut to [`MAX_FONT_NAME`].
|
||||
StyleName,
|
||||
/// ODT `text:s` spaces past the document's budget.
|
||||
Spaces,
|
||||
/// Markdown table rows and columns past the table limits or the document's cell budget.
|
||||
TableCells,
|
||||
}
|
||||
|
||||
impl Dropped {
|
||||
@@ -69,23 +65,8 @@ impl Dropped {
|
||||
format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them")
|
||||
}
|
||||
Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"),
|
||||
Dropped::Spaces => {
|
||||
"import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string()
|
||||
}
|
||||
Dropped::TableCells => format!(
|
||||
"import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out",
|
||||
wordcraft_doc::table::MAX_COLS,
|
||||
wordcraft_doc::table::MAX_ROWS,
|
||||
crate::markdown::MAX_TABLE_CELLS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this thread's import has logged this kind ([`dropped`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reported(self) -> bool {
|
||||
REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log that the importer left something of `kind` out: once per kind per imported document
|
||||
|
||||
@@ -43,7 +43,7 @@ use import::Pages;
|
||||
use import::{Pictures, apply_page_layout, read_entry, read_meta};
|
||||
pub use import::{import, parse};
|
||||
#[cfg(test)]
|
||||
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
|
||||
use limits::MAX_XML_DEPTH;
|
||||
pub use package::export;
|
||||
use styles::Styles;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::import::Pages;
|
||||
use super::limits::{MAX_EXTRA_SPACES, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
|
||||
use super::limits::{MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
|
||||
use super::styles::Styles;
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model};
|
||||
@@ -116,8 +116,6 @@ pub(super) struct Body<'a> {
|
||||
para: Option<Para>,
|
||||
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
|
||||
xml_space_end: bool,
|
||||
/// What is left of [`MAX_EXTRA_SPACES`].
|
||||
pub(super) spaces_left: usize,
|
||||
para_cont: bool,
|
||||
/// Open lists: (style name, item open, item used).
|
||||
lists: Vec<(Option<String>, bool)>,
|
||||
@@ -171,7 +169,6 @@ impl<'a> Body<'a> {
|
||||
fmt: Vec::new(),
|
||||
para: None,
|
||||
xml_space_end: false,
|
||||
spaces_left: MAX_EXTRA_SPACES,
|
||||
para_cont: false,
|
||||
lists: Vec::new(),
|
||||
lists_saved: Vec::new(),
|
||||
@@ -824,16 +821,9 @@ impl Body<'_> {
|
||||
}
|
||||
self.fmt.push(fmt);
|
||||
}
|
||||
// Outside a paragraph `push_text` drops spaces, so they cost no budget.
|
||||
"s" if self.para.is_some() => {
|
||||
"s" => {
|
||||
let count = get(a, "text:c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
let want = count.saturating_sub(1);
|
||||
let extra = want.min(self.spaces_left);
|
||||
if extra < want {
|
||||
model::dropped(model::Dropped::Spaces);
|
||||
}
|
||||
self.spaces_left -= extra;
|
||||
self.push_text(&" ".repeat(1 + extra));
|
||||
self.push_text(&" ".repeat(count));
|
||||
}
|
||||
"tab" => self.push_text("\t"),
|
||||
"line-break" => self.push_text("\n"),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use super::body::Body;
|
||||
use super::limits::{MAX_ENTRY, MAX_EXTRA_SPACES, MAX_SECTIONS};
|
||||
use super::limits::{MAX_ENTRY, MAX_SECTIONS};
|
||||
use super::styles::{Styles, column_sep, columns_of};
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
|
||||
let f = z.by_name(name).ok()?;
|
||||
@@ -139,11 +138,8 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Opti
|
||||
// A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded.
|
||||
pictures.get(&mut z, path)
|
||||
};
|
||||
// One `text:s` budget for the headers and footers and then the body.
|
||||
let mut spaces_left = MAX_EXTRA_SPACES;
|
||||
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, &mut spaces_left)).unwrap_or_default();
|
||||
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch)).unwrap_or_default();
|
||||
let mut body = Body::new(&styles, &pages, &mut fetch);
|
||||
body.spaces_left = spaces_left;
|
||||
events(&content, |ev| body.feed(ev));
|
||||
let mut blocks = body.take_blocks();
|
||||
// An open section with nothing in it (after a column section at the end) isn't one: the
|
||||
@@ -168,7 +164,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
let (flow, last, track, font, parts) = parse_tracked(bytes)?;
|
||||
let mut doc = model::to_doc(&flow);
|
||||
if font.is_some() {
|
||||
Arc::make_mut(&mut doc.styles).default_chr.font = font;
|
||||
doc.styles.default_chr.font = font;
|
||||
}
|
||||
doc.last_section = last;
|
||||
page_parts::add(&mut doc, parts);
|
||||
|
||||
@@ -15,5 +15,3 @@ pub(super) const MAX_OPEN_INSERTIONS: usize = 64;
|
||||
|
||||
/// Longest change author or date kept, in characters.
|
||||
pub(super) const MAX_INFO_CHARS: usize = 256;
|
||||
/// Most spaces the document's `text:s` elements add beyond the one each stands for (hostile input).
|
||||
pub(super) const MAX_EXTRA_SPACES: usize = 4 << 20;
|
||||
|
||||
@@ -58,9 +58,8 @@ pub(super) struct Parts {
|
||||
|
||||
/// Read each visible header and footer of the master pages in `pages`, and name it on its master
|
||||
/// page's section as part id 1, 2, … in order, so the sections the body starts carry them.
|
||||
/// [`add`] puts those parts into the document. `spaces_left` is the document's
|
||||
/// [`MAX_EXTRA_SPACES`] budget, which the body read after them goes on with.
|
||||
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>, spaces_left: &mut usize) -> Parts {
|
||||
/// [`add`] puts those parts into the document.
|
||||
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>) -> Parts {
|
||||
let mut found: Vec<(String, Slot, Vec<Ev>)> = Vec::new();
|
||||
let mut master: Option<String> = None;
|
||||
// The slot being read, its nesting depth and whether it is kept.
|
||||
@@ -110,11 +109,9 @@ pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut d
|
||||
for (name, kind, evs) in found {
|
||||
let mut reader = Body::new(styles, &none, fetch);
|
||||
reader.in_text = true;
|
||||
reader.spaces_left = *spaces_left;
|
||||
for ev in evs {
|
||||
reader.feed(ev);
|
||||
}
|
||||
*spaces_left = reader.spaces_left;
|
||||
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
|
||||
if let Some(sec) = pages.masters.get_mut(&name) {
|
||||
kind.set(sec, out.parts.len() as u32);
|
||||
|
||||
@@ -488,24 +488,6 @@ fn deeply_nested_header_content_does_not_panic() {
|
||||
assert_eq!(part_text(&d, d.last_section.footers.default), "After");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_footers_share_the_text_s_budget_with_the_body() {
|
||||
// Each header and footer was read with a budget of its own (#3700).
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(3_000);
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>h{run}</text:p></style:header><style:footer><text:p>f{run}</text:p></style:footer></style:master-page></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>b{run}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let texts =
|
||||
[part_text(&d, d.last_section.headers.default), part_text(&d, d.last_section.footers.default), d.plain_text(wordcraft_doc::StoryRef::Body)];
|
||||
// One space per element in all three, and one budget of extra spaces between them.
|
||||
assert_eq!(texts.iter().map(|t| t.matches(' ').count()).sum::<usize>(), 3 * 3_000 + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("h ") && texts[1].starts_with("f ") && texts[2].starts_with("b "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_and_column_sections_become_sections() {
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
@@ -1067,38 +1049,6 @@ fn explicit_spaces_at_paragraph_ends_are_kept() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_spaces_have_a_budget_for_the_whole_document() {
|
||||
// 5,000 elements asking for 1,000 spaces each would add 5 million (#3700).
|
||||
let tags = 5_000;
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(tags);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>a{run}b</text:p><text:p>c<text:s text:c="3"/>d</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
// Once the budget is spent, each element still stands for one space, and the cut is logged.
|
||||
assert_eq!(texts[0].len(), 2 + tags + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("a ") && texts[0].ends_with(" b"));
|
||||
assert_eq!(texts[1], "c d");
|
||||
assert!(crate::model::Dropped::Spaces.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_outside_a_paragraph_spends_no_budget() {
|
||||
// Spaces outside a paragraph are dropped, so they mustn't use up the budget (#3700).
|
||||
let stray = r#"<text:s text:c="1000"/>"#.repeat(5_000);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text>{stray}<text:p>a<text:s text:c="1000"/>b</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
assert_eq!(texts, vec![format!("a{}b", " ".repeat(1000))]);
|
||||
assert!(!crate::model::Dropped::Spaces.reported(), "nothing was cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asian_complex_and_extended_text_properties() {
|
||||
let content = format!(
|
||||
|
||||
@@ -33,12 +33,6 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// [`esc`] for a `\fonttbl` name: `;` would end the entry, so it is written `\'3b`, and control
|
||||
/// characters, which `esc` writes as `\line` or `\tab`, are dropped.
|
||||
fn esc_font(name: &str) -> String {
|
||||
esc(&name.replace(char::is_control, "")).replace(';', "\\'3b")
|
||||
}
|
||||
|
||||
pub(super) struct Tables {
|
||||
pub(super) fonts: Vec<String>,
|
||||
pub(super) colors: Vec<Rgb>,
|
||||
@@ -241,9 +235,7 @@ impl Writer {
|
||||
// leaks the reader's NTLM credentials.
|
||||
match f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||||
Some(l) => {
|
||||
// In a field's quotes `\\` is one backslash; a quote is stored as `%22`.
|
||||
let target = l.replace('\\', "\\\\").replace('"', "%22");
|
||||
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&target), body));
|
||||
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&l.replace('"', "%22")), body));
|
||||
}
|
||||
None => out.push_str(&body),
|
||||
}
|
||||
@@ -690,7 +682,7 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl");
|
||||
for (i, f) in w.t.fonts.iter().enumerate() {
|
||||
let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" };
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f)));
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc(f)));
|
||||
}
|
||||
out.push_str("}\n{\\colortbl;");
|
||||
for c in &w.t.colors {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::reader::Reader;
|
||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
@@ -306,12 +305,12 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
}
|
||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||
// wants beyond that it states on each paragraph.
|
||||
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||
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() {
|
||||
Arc::make_mut(&mut doc.styles).default_chr.font = default_font;
|
||||
doc.styles.default_chr.font = default_font;
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -1229,40 +1229,6 @@ fn a_code_paragraph_round_trips_without_a_direct_code_font() {
|
||||
assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}");
|
||||
}
|
||||
|
||||
/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862).
|
||||
#[test]
|
||||
fn escaped_semicolons_in_a_font_name_stop_at_the_limit() {
|
||||
let mut r = reader::Reader::default();
|
||||
r.st.dest = state::Dest::FontTbl;
|
||||
for _ in 0..100_000 {
|
||||
r.ch(';');
|
||||
}
|
||||
assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1);
|
||||
let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1));
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
assert_eq!(run_fmt(&runs, "x").font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_semicolon_in_a_font_name_round_trips() {
|
||||
// `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740).
|
||||
let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")];
|
||||
let mut p = Para::default();
|
||||
for (t, font) in fonts {
|
||||
p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() });
|
||||
}
|
||||
let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() };
|
||||
let rtf = export(&model::to_doc(&flow));
|
||||
assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}");
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
for (t, font) in fonts {
|
||||
assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}");
|
||||
}
|
||||
// A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it.
|
||||
let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}");
|
||||
assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stylesheet_entry_without_a_number_is_normal() {
|
||||
// An entry without `\sN` is style 0; it doesn't replace the entry before it.
|
||||
@@ -1592,42 +1558,8 @@ fn hyperlink_field_targets() {
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
|
||||
assert_eq!(hyperlink(r#"Hyperlink "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/pathlatest".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
|
||||
// A tooltip or frame before the address, a longer field name, escapes and the writer's `%22`.
|
||||
assert_eq!(hyperlink(r#"HYPERLINK \o "tip" \t "_blank" "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINKX "http://example.com""#), None);
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "C:\\Docs\\f.rtf""#), Some(r"C:\Docs\f.rtf".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/a%22b""#), Some(r#"http://example.com/a"b"#.into()));
|
||||
}
|
||||
|
||||
/// A link with a quote in it survives export and import: the writer stores it as `%22`.
|
||||
#[test]
|
||||
fn a_quote_in_a_link_round_trips() {
|
||||
let rtf = br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "http://a.b/x%22y"}}{\fldrslt{link}}}\par}"#;
|
||||
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
|
||||
let d = import(rtf).unwrap();
|
||||
assert_eq!(link(&d).as_deref(), Some(r#"http://a.b/x"y"#));
|
||||
let exported = export(&d);
|
||||
assert!(exported.contains("x%22y"), "{exported}");
|
||||
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(r#"http://a.b/x"y"#));
|
||||
}
|
||||
|
||||
/// Backslashes in a link survive export and import, a trailing one and two in a row too.
|
||||
#[test]
|
||||
fn backslashes_in_a_link_round_trip() {
|
||||
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
|
||||
for target in [r"docs\", r"a\\b", r"..\up\f.doc"] {
|
||||
let mut d = import(br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "x"}}{\fldrslt{link}}}\par}"#).unwrap();
|
||||
for b in d.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
p.runs.iter_mut().filter(|r| r.props.link.is_some()).for_each(|r| r.props.link = Some(target.into()));
|
||||
}
|
||||
}
|
||||
assert_eq!(link(&d).as_deref(), Some(target));
|
||||
let exported = export(&d);
|
||||
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(target), "{exported}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding>
|
||||
|
||||
impl Reader {
|
||||
pub(super) fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().to_string();
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
|
||||
&& self.fonts.len() < 4096
|
||||
{
|
||||
@@ -36,11 +36,6 @@ impl Reader {
|
||||
self.skip_chars -= 1;
|
||||
return;
|
||||
}
|
||||
// `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`).
|
||||
if b == b';' && self.st.dest == Dest::FontTbl {
|
||||
self.ch(';');
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
let Some(encoding) = multibyte_encoding(cp) else {
|
||||
self.finish_multibyte();
|
||||
@@ -111,11 +106,10 @@ impl Reader {
|
||||
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
|
||||
/// BOM and the rest decoded as UTF-8.
|
||||
pub(super) fn run(&mut self, chunk: &[u8]) {
|
||||
// The fallback bytes after `\uN` are skipped; the rest of the run is text like any other.
|
||||
let skip = self.skip_chars.min(chunk.len());
|
||||
self.skip_chars -= skip;
|
||||
let chunk = chunk.get(skip..).unwrap_or_default();
|
||||
if chunk.is_empty() {
|
||||
if self.skip_chars > 0 {
|
||||
for b in chunk {
|
||||
self.byte(*b);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
@@ -308,14 +302,6 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
self.finish_multibyte();
|
||||
// Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it.
|
||||
if c == ';' && self.st.dest == Dest::FontTbl {
|
||||
// Held to the limit as other characters are; one past it drops the name.
|
||||
if self.font_name.len() <= model::MAX_FONT_NAME {
|
||||
self.font_name.push(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
self.text(c.encode_utf8(&mut buf));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user