Compare commits

..
404 changed files with 4529 additions and 42723 deletions
Generated
+25 -63
View File
@@ -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,34 +6906,26 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"aes",
"base64 0.23.1",
"cfb",
"chardetng",
"chrono",
"encoding_rs",
"getrandom 0.3.4",
"hmac",
"image",
"js-sys",
"log",
"miniz_oxide 0.9.1",
"proptest",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.1",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zeroize",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"serde",
@@ -6974,7 +6933,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"craft-proof",
"image",
@@ -6994,7 +6953,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"log",
"serde_json",
@@ -7006,7 +6965,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7014,7 +6973,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -7035,7 +6994,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"image",
"log",
@@ -7049,9 +7008,8 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"accesskit_consumer",
"cfb",
"chrono",
"craft-i18n",
@@ -7059,7 +7017,6 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"image",
"js-sys",
@@ -7082,7 +7039,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -7101,7 +7058,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"log",
"proptest",
@@ -7264,7 +7221,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.15.0"
dependencies = [
"serde_json",
]
@@ -7623,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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -89
View File
File diff suppressed because one or more lines are too long
+1 -32
View File
@@ -68,10 +68,6 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
";
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
@@ -90,18 +86,11 @@ fn missing_argument() -> Failure {
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
open_in(path, password, odf_profile()?)
}
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
/// `file.new` in a `run` script reads files the same way.
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region, &odf)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let mut s = session(doc);
s.prefs.odf = odf;
s.path = Some(path.into());
if encrypted {
s.password = password.map(wordcraft_engine::io::Password::new);
@@ -183,24 +172,6 @@ fn session(doc: wordcraft_doc::Document) -> Session {
s
}
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
odf_from(std::env::var("WORDCRAFT_ODF").ok())
}
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
match var.filter(|v| !v.trim().is_empty()) {
None => Ok(OdfProfile::default()),
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
Some(p) => Ok(OdfProfile::of(p)),
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
},
}
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
@@ -533,8 +504,6 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
// run starts with one open document.
(None, t) => {
let mut s = session(wordcraft_doc::Document::new());
// The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style.
s.prefs.odf = odf_profile()?;
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(|e| e.to_string())?;
-18
View File
@@ -543,21 +543,3 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run`
/// script's later `file.open` reads OpenDocument files the same way.
#[test]
fn the_odf_style_is_kept_by_the_session() {
use wordcraft_doc::odf_profile::OdfProfile;
assert_eq!(odf_from(None), Ok(OdfProfile::default()));
assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice()));
assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word()));
assert!(odf_from(Some("custom".into())).is_err());
let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.txt");
std::fs::write(&path, "Hello").unwrap();
let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap();
assert_eq!(s.prefs.odf, OdfProfile::libreoffice());
let _ = std::fs::remove_dir_all(&dir);
}
+1 -3
View File
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
@@ -655,8 +655,6 @@ fn mime_for(name: &str) -> &'static str {
Some("pdf") => "application/pdf",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("odt") => "application/vnd.oasis.opendocument.text",
Some("ott") => "application/vnd.oasis.opendocument.text-template",
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
Some("rtf") => "application/rtf",
Some("html") => "text/html",
Some("md" | "txt") => "text/plain",
-2
View File
@@ -239,8 +239,6 @@ fn format_name(ext: &str) -> String {
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"ott" => "OpenDocument Text Template".into(),
"fodt" => "Flat OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
059cbf95c2a3eb95d637ca8584faca1129d17f48
-1
View File
@@ -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};
+1 -6
View File
@@ -213,12 +213,6 @@ pub struct ShapeExtra {
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
/// it). Dropped when the shape is made another kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf_geometry: Option<std::sync::Arc<String>>,
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -390,6 +384,7 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
-12
View File
@@ -254,18 +254,6 @@ pub struct ContentControl {
/// `w:sdtEndPr`, kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub end_pr_xml: String,
/// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and
/// `svg:height`), points: kept for saving back as one.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_size: Option<[f32; 2]>,
/// How far that form control stands raised from the baseline (points, negative lowered:
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_raise: Option<f32>,
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
#[serde(skip_serializing_if = "String::is_empty")]
pub form_xml: String,
}
impl ContentControl {
+3 -52
View File
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, 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(),
@@ -76,7 +76,6 @@ impl Document {
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
}
}
@@ -153,7 +152,6 @@ impl Document {
passthrough,
glossary,
media_index: _,
kept: _,
} = self;
let Document {
body: other_body,
@@ -173,7 +171,6 @@ impl Document {
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
kept: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
@@ -737,52 +734,6 @@ impl Document {
}
}
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
/// drawing from its data), which doesn't change the document: the markup kept as read for
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
// The stories (text boxes', notes') as read before, when filling changes one: they stay
// as read after it, so the paragraphs showing them keep their markup.
let holds = |b: &Arc<Block>| {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
any
};
let as_read: Vec<u32> = match self.kept.get() {
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
}
_ => Vec::new(),
};
let mut moves: Vec<crate::kept::Moved> = Vec::new();
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
if !any {
continue;
}
let before = crate::kept::identities(b);
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
if p.objects.iter().any(wants) {
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
p.touch();
}
});
moves.push((before, crate::kept::identities(b)));
}
}
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
if let Some(k) = self.kept.get_mut() {
for (before, after) in moves {
k.follow(&before, &after);
}
k.story_fps.extend(refreshed);
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
+4 -9
View File
@@ -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);
+19 -39
View File
@@ -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}");
}
-11
View File
@@ -51,17 +51,6 @@ pub struct Embedded {
pub refs: Vec<EmbeddedRef>,
}
impl Embedded {
/// The root element of an ODF embedded object's markup: its files are kept in
/// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture.
pub const ODF_OBJECT: &'static str = "<wordcraft:odf-object";
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
pub fn is_odf_object(&self) -> bool {
self.xml.trim_start().starts_with(Self::ODF_OBJECT)
}
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
-697
View File
@@ -1,697 +0,0 @@
//! Markup kept from the file a document was opened from, so that what isn't edited is written
//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the
//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the
//! model has no place for (an unknown element, an attribute nobody reads) survives a save that
//! way. Never saved with the document (WordCraft's own format), and not part of its content
//! (`==` ignores it).
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use crate::Paragraph;
/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without
/// bound: past it, paragraphs are written from the model as before).
pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024;
/// The kept markup of a document (see the module docs).
#[derive(Clone, Debug, Default)]
pub struct Kept(Option<Arc<KeptXml>>);
/// What a reader kept.
#[derive(Clone, Debug, Default)]
pub struct KeptXml {
/// The format it came from (`"odt"`): only that format's writer uses it.
pub format: String,
/// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical
/// paragraphs keep the first's).
pub paras: HashMap<u64, Arc<str>>,
/// The same for the body's tables ([`table_fingerprint`]).
pub tables: HashMap<u64, Arc<str>>,
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
pub pending_tables: Vec<(usize, Arc<str>)>,
/// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the
/// start tags opening before it and how many close after it. Only paragraphs that were the
/// only one of their kind have them (a copy can't open a section twice).
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and
/// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once).
pub twin_wraps: HashMap<(u64, u64), (Vec<Arc<str>>, u32)>,
/// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are
/// the only one of their kind).
pub table_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: those of the body's `n`th table, until `seal_kept`.
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, u32))>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
pub groups: HashMap<u64, (u32, u32)>,
/// While reading: the same by paragraph `rev`, until `seal_kept`.
pub pending_groups: HashMap<u64, (u32, u32)>,
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
/// showing one is written as read only while it is as read.
pub story_fps: HashMap<u32, u64>,
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
/// paragraph holding comments is written as read only while they are as read.
pub comment_fps: HashMap<u32, u64>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
/// them into fingerprints.
pub pending: HashMap<u64, Arc<str>>,
/// Bytes kept so far.
pub bytes: usize,
/// The keys of [`KeptXml::defs`] in the order the file had them.
pub order: Vec<String>,
/// Files of the package kept definitions refer to (a style's fill picture), by path.
pub files: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's style definitions' [`styles_fingerprint`] when read: while it is the
/// same, the file's own style definitions can be written as they were.
pub styles_fp: Option<u64>,
/// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the
/// file's own page layouts and master pages can be written as they were.
pub pages_fp: Option<u64>,
/// Each section's master page, as the file named it where its section starts (`None`: the
/// file's default), for a writer naming the file's own master pages.
pub section_masters: Vec<Option<String>>,
}
impl PartialEq for Kept {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Kept {
pub fn new(x: KeptXml) -> Kept {
Kept(Some(Arc::new(x)))
}
pub fn get(&self) -> Option<&KeptXml> {
self.0.as_deref()
}
/// What `format`'s writer may use, if anything was kept from such a file.
pub fn of(&self, format: &str) -> Option<&KeptXml> {
self.get().filter(|k| k.format == format)
}
pub fn get_mut(&mut self) -> Option<&mut KeptXml> {
self.0.as_mut().map(Arc::make_mut)
}
pub fn clear(&mut self) {
self.0 = None;
}
/// After reading: each paragraph still marked pending (by its `rev`, which any change after
/// the reader made it would have moved on) gets its markup under its fingerprint.
pub fn seal(&mut self, paras: &mut dyn Iterator<Item = &Paragraph>) {
let Some(k) = self.get_mut() else { return };
if k.pending.is_empty() {
return;
}
for p in paras {
if let Some(xml) = k.pending.remove(&p.rev) {
k.paras.entry(fingerprint(p)).or_insert(xml);
}
}
k.pending.clear();
}
}
impl KeptXml {
/// The markup kept for paragraph `p`, while it still is what was read.
pub fn para(&self, p: &Paragraph) -> Option<&Arc<str>> {
if self.paras.is_empty() {
return None;
}
self.paras.get(&fingerprint(p))
}
/// The markup kept for table `t`, while it still is what was read.
pub fn table(&self, t: &crate::Table) -> Option<&Arc<str>> {
if self.tables.is_empty() {
return None;
}
self.tables.get(&table_fingerprint(t))
}
/// Whether the comments paragraph `p` holds (those it begins, with their replies) are as
/// read and are all the comments its kept markup `xml` holds: a reply written elsewhere
/// in the file, or one added since, isn't in it.
pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool {
let in_markup = xml.matches("<office:annotation ").count() + xml.matches("<office:annotation>").count();
let mut count = 0usize;
for o in &p.objects {
if let crate::para::InlineObject::CommentStart { id } = o {
let (fp, n) = comments_fingerprint(doc, *id);
if self.comment_fps.get(id) != Some(&fp) {
return false;
}
count = count.saturating_add(n);
}
}
count == in_markup
}
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
/// as read.
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
// A note's citation is its number in the document's numbering, which is the page setup's.
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
if notes && !self.pages_as_read(doc) {
return false;
}
p.objects
.iter()
.flat_map(|o| match o {
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
o => o.text_boxes(),
})
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
}
/// The wrappers kept around table `t`, while it still is what was read.
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
if self.table_wraps.is_empty() {
return None;
}
self.table_wraps.get(&table_fingerprint(t))
}
/// The wrappers kept around paragraph `p`, while it still is what was read.
pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec<Arc<str>>, u32)> {
if self.wraps.is_empty() && self.twin_wraps.is_empty() {
return None;
}
let fp = fingerprint(p);
self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev)))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
if self.pending_groups.len() < 100_000 {
self.pending_groups.insert(rev, group);
}
}
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
if self.groups.is_empty() {
return None;
}
self.groups.get(&fingerprint(p)).copied()
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES {
self.bytes += n;
self.pending_wraps.insert(rev, wrap);
}
}
/// Keep wrapper markup for the body's `n`th table, within the budget.
pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec<Arc<str>>, u32)) {
let size: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES {
self.bytes += size;
self.pending_table_wraps.push((n, wrap));
}
}
/// Keep `xml` for the body's `n`th table, within the budget.
pub fn keep_pending_table(&mut self, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_tables.push((n, xml));
}
}
/// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending(&mut self, rev: u64, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending.insert(rev, xml);
}
}
/// Keep a named definition, within the budget.
pub fn keep_def(&mut self, key: String, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) {
self.bytes += xml.len();
self.order.push(key.clone());
self.defs.insert(key, xml);
}
}
/// Keep a file kept definitions refer to, within the budget.
pub fn keep_file(&mut self, path: String, bytes: Vec<u8>) {
if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) {
self.bytes += bytes.len();
self.files.insert(path, Arc::new(bytes));
}
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc))
}
}
/// What `doc`'s style definitions are: its styles, lists and default tab interval.
pub fn styles_fingerprint(doc: &crate::Document) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and
/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`].
pub struct Identities {
paras: Vec<(u64, u64)>,
table: Option<u64>,
}
/// A block's [`Identities`] before and after a change that keeps what it is.
pub type Moved = (Identities, Identities);
/// The [`Identities`] of block `b`.
pub fn identities(b: &crate::Block) -> Identities {
let mut paras_of = Vec::new();
match b {
crate::Block::Para(p) => paras_of.push(p),
crate::Block::Table(t) => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, 1, &mut paras_of);
}
}
}
}
Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) }
}
impl KeptXml {
/// The markup kept for a block's paragraphs and table follows them from `before` to `after`
/// (what they were and are: see [`crate::Document::fill_objects`]).
pub fn follow(&mut self, before: &Identities, after: &Identities) {
for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) {
if fp == nfp && rev == nrev {
continue;
}
if let Some(x) = self.paras.remove(fp) {
self.paras.entry(*nfp).or_insert(x);
}
if let Some(w) = self.wraps.remove(fp) {
self.wraps.entry(*nfp).or_insert(w);
}
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
}
// Its group too: all a group's paragraphs are written as read, or none.
if let Some(g) = self.groups.remove(fp) {
self.groups.entry(*nfp).or_insert(g);
}
}
if let (Some(t), Some(nt)) = (before.table, after.table)
&& t != nt
{
if let Some(x) = self.tables.remove(&t) {
self.tables.entry(nt).or_insert(x);
}
if let Some(w) = self.table_wraps.remove(&t) {
self.table_wraps.entry(nt).or_insert(w);
}
}
}
}
/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes).
const MAX_STORY_DEPTH: usize = 8;
/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes
/// they hold in turn.
pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut todo = vec![(id, 0usize)];
let mut seen = std::collections::HashSet::new();
while let Some((id, depth)) = todo.pop() {
if !seen.insert(id) {
continue;
}
let blocks = doc.parts.get(&id).map(|p| &p.blocks);
match serde_json::to_vec(&(id, blocks)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
if depth >= MAX_STORY_DEPTH {
continue;
}
let Some(blocks) = blocks else { continue };
let mut held = Vec::new();
paras(blocks, 0, &mut held);
for p in held {
for o in &p.objects {
todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1)));
if let crate::para::InlineObject::NoteRef { id, .. } = o {
todo.push((*id, depth + 1));
}
}
}
}
h.finish()
}
/// Most replies a comment's fingerprint follows (hostile input).
const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &replies(doc))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
out.entry(p).or_default().push(*id);
}
}
out
}
/// [`comments_fingerprint`], with each comment's `replies`.
fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, Vec<u32>>) -> (u64, usize) {
let mut ids = vec![root];
let mut seen = std::collections::HashSet::from([root]);
let mut i = 0;
while let Some(&id) = ids.get(i) {
for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) {
// (A reply naming itself, or a circle of them, is followed once.)
if ids.len() < MAX_REPLIES && seen.insert(*r) {
ids.push(*r);
}
}
i += 1;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for id in &ids {
let c = doc.comments.get(id);
let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks);
match serde_json::to_vec(&(c, text)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
}
(h.finish(), ids.len())
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
let parts: Vec<(&u32, &crate::Part)> =
doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect();
let s = &doc.settings;
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(
sections,
parts,
&s.page_color,
&s.page_picture,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(t) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`):
/// equal for a paragraph nobody changed.
pub fn fingerprint(p: &Paragraph) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(p) {
Ok(v) => v.hash(&mut h),
// Unserializable (no paragraph is): a value no paragraph's markup is kept under.
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// Deepest table nesting [`crate::Document::seal_kept`] looks into.
const MAX_SEAL_DEPTH: usize = 16;
fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) {
for b in bl {
match &**b {
crate::Block::Para(p) => out.push(p),
crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, depth + 1, out);
}
}
}
crate::Block::Table(_) => {}
}
}
}
impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
if let Some(t) = tables.get(n) {
k.tables.entry(table_fingerprint(t)).or_insert(xml);
}
}
// Wrappers, for tables that are the only one of their kind (a copy can't open a
// section twice).
let pending = std::mem::take(&mut k.pending_table_wraps);
if !pending.is_empty() {
let fps: Vec<u64> = tables.iter().map(|t| table_fingerprint(t)).collect();
for (n, w) in pending {
if let Some(fp) = fps.get(n)
&& fps.iter().filter(|f| *f == fp).count() == 1
{
k.table_wraps.insert(*fp, w);
}
}
}
}
// The text boxes' and shapes' stories as read, for paragraphs showing them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
let fps: HashMap<u32, u64> = self
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
.collect();
if let Some(k) = self.kept.get_mut() {
k.story_fps = fps;
}
}
// The comments as read, for paragraphs holding them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() {
let replies = replies(self);
let fps: HashMap<u32, u64> =
self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect();
if let Some(k) = self.kept.get_mut() {
k.comment_fps = fps;
}
}
if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) {
return;
}
let mut all = Vec::new();
paras(&self.body, 0, &mut all);
for p in self.parts.values() {
paras(&p.blocks, 0, &mut all);
}
let mut kept = std::mem::take(&mut self.kept);
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
// tell apart) isn't kept.
if let Some(k) = kept.get_mut()
&& !k.pending_groups.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
let pending = std::mem::take(&mut k.pending_groups);
let mut broken = std::collections::HashSet::new();
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
for (rev, fp) in &fps {
if let Some(g) = pending.get(rev) {
if count.get(fp) != Some(&1) {
broken.insert(g.0);
}
members.push((*rev, *fp, *g));
}
}
for (rev, fp, g) in members {
if broken.contains(&g.0) {
k.pending.remove(&rev);
} else {
k.groups.insert(fp, g);
}
}
}
// Wrappers, for paragraphs that are the only one of their kind.
if let Some(k) = kept.get_mut()
&& !k.pending_wraps.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
for (rev, fp) in fps {
if let Some(w) = k.pending_wraps.remove(&rev) {
if count.get(&fp) == Some(&1) {
k.wraps.insert(fp, w);
} else {
k.twin_wraps.insert((fp, rev), w);
}
}
}
k.pending_wraps.clear();
}
kept.seal(&mut all.into_iter());
self.kept = kept;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_paragraph_keeps_its_markup_until_it_changes() {
let mut p = Paragraph::with_text("Hello", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hello</text:p>"));
}
kept.seal(&mut std::iter::once(&p));
let k = kept.of("odt").unwrap();
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
assert!(k.pending.is_empty());
// The same paragraph again (a copy, its rev moved on): still what was read.
p.touch();
assert!(k.para(&p).is_some());
// Changed: written from the model.
p.text.push('!');
assert!(k.para(&p).is_none());
assert!(kept.of("docx").is_none());
}
#[test]
fn a_paragraph_changed_while_reading_keeps_nothing() {
let mut p = Paragraph::with_text("Hi", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hi</text:p>"));
}
p.touch();
kept.seal(&mut std::iter::once(&p));
assert!(kept.of("odt").unwrap().paras.is_empty());
}
#[test]
fn kept_markup_is_bounded() {
let mut k = KeptXml::default();
let big: Arc<str> = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1));
k.keep_pending(1, big.clone());
k.keep_pending(2, big);
assert_eq!(k.pending.len(), 1);
}
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
#[test]
fn filling_objects_keeps_groups_and_stories_as_read() {
use crate::para::InlineObject;
let field =
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
let mut doc = crate::Document::from_text("Body");
let mut boxed = Paragraph::with_text("In the box", Default::default());
boxed.insert_object(0, field(), &Default::default()).unwrap();
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
let fp = fingerprint(doc.body[0].as_para().unwrap());
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
k.groups.insert(fp, (1, 1));
k.story_fps.insert(id, story_fingerprint(&doc, id));
doc.kept = Kept::new(k);
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
if let InlineObject::Field { result, .. } = o {
*result = "filled".into();
}
});
let p = doc.body[0].as_para().unwrap();
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
let k = doc.kept.get().unwrap();
assert!(k.para(p).is_some());
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
}
+5 -15
View File
@@ -2,9 +2,9 @@
//!
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
//! table cells hold block lists of their own. Blocks, 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.
@@ -28,7 +28,6 @@ pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod kept;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -39,7 +38,6 @@ pub mod math_latex;
pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod odf_profile;
pub mod para;
pub mod props;
pub mod resolve;
@@ -100,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,
@@ -133,10 +127,6 @@ pub struct Document {
/// by `==`).
#[serde(skip)]
media_index: MediaIndex,
/// Markup kept from the file it was opened from, for writing back what isn't edited (see
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
}
#[cfg(test)]
+2 -114
View File
@@ -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"] {
-255
View File
@@ -1,255 +0,0 @@
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
//!
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
//! remaining cases an implementation-dependent value is used"), each application chooses, and
//! documents from one application can look different in another. An [`OdfProfile`] is that
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Whose choices fill in what the standard leaves open.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OdfPreset {
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
/// (`wordcraft`, its earlier id, is still taken.)
#[default]
#[serde(alias = "wordcraft")]
Word,
/// LibreOffice's (and Collabora's, which share its engine).
LibreOffice,
/// The user's own choice for each case ([`OdfProfile`]'s values).
Custom,
}
impl OdfPreset {
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
pub fn id(self) -> &'static str {
match self {
OdfPreset::Word => "word",
OdfPreset::LibreOffice => "libreoffice",
OdfPreset::Custom => "custom",
}
}
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
pub fn from_id(id: &str) -> Option<OdfPreset> {
let id = id.trim();
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
return Some(OdfPreset::Word);
}
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
}
/// The two styles a document can be shown in, as the user chooses between them (Custom
/// aside).
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
/// Its name as the user sees it (File › Options, English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
OdfPreset::Word => "Microsoft Word style",
OdfPreset::LibreOffice => "LibreOffice style",
OdfPreset::Custom => "Custom",
}
}
}
/// The most a length in a profile may be, points (22 inches).
pub const MAX_LENGTH: f32 = 1584.0;
/// The choice for each case the standard leaves open (see the module docs). With a preset other
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
/// ([`OdfProfile::effective`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfProfile {
pub preset: OdfPreset,
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
/// the standard doesn't say (LibreOffice uses 2 cm).
pub page_margin: f32,
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
/// has no default), points.
pub header_min_height: f32,
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
/// coming after it.
pub header_height_includes_spacing: bool,
/// What a header or footer whose style doesn't say does with its spacing when its content
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
/// drop the spacing the file states).
pub dynamic_spacing: bool,
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
pub tabs_from_indent: bool,
/// The gap between columns whose `style:columns` states neither one nor each column
/// (§19.233 `fo:column-gap` has no default), points.
pub column_gap: f32,
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
/// default).
pub shape_fill: Rgb,
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
pub shape_line: Rgb,
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
/// (§20.410 `svg:stroke-width` gives neither), points.
pub hairline: f32,
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
/// define) is placed against the page (`true`), else against the page's text area.
pub page_frame_paragraph_is_page: bool,
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
pub inline_invalid_relation_is_baseline: bool,
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
/// its text would be too (the standard defines no line-height algorithm).
pub picture_lines_fit_pictures: bool,
/// A columned section that doesn't say whether its columns are balanced (§20.428
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
/// its name) ends with balanced columns at the end of the document too (`true`), else only
/// when text follows on the same page, as in Word.
pub balance_section_columns: bool,
/// The space between two paragraphs is the first's space after plus the second's space
/// before (`true`), else the larger of the two, when the document doesn't say (the
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
pub sum_paragraph_spacing: bool,
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
pub widow_control: bool,
}
impl Default for OdfProfile {
fn default() -> Self {
OdfProfile::word()
}
}
impl OdfProfile {
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
pub fn word() -> OdfProfile {
OdfProfile {
preset: OdfPreset::Word,
page_margin: 0.0,
header_min_height: 0.0,
header_height_includes_spacing: false,
dynamic_spacing: false,
tabs_from_indent: false,
column_gap: 36.0,
shape_fill: Rgb(0x15, 0x60, 0x82),
shape_line: Rgb(0x0E, 0x40, 0x5A),
hairline: 0.25,
page_frame_paragraph_is_page: false,
inline_invalid_relation_is_baseline: false,
picture_lines_fit_pictures: false,
balance_section_columns: false,
sum_paragraph_spacing: false,
widow_control: true,
}
}
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
pub fn libreoffice() -> OdfProfile {
OdfProfile {
preset: OdfPreset::LibreOffice,
page_margin: 2.0 * 72.0 / 2.54,
header_min_height: 0.5 * 72.0 / 2.54,
header_height_includes_spacing: true,
dynamic_spacing: false,
tabs_from_indent: true,
column_gap: 0.0,
shape_fill: Rgb(0x72, 0x9F, 0xCF),
shape_line: Rgb(0x34, 0x65, 0xA4),
hairline: 0.5,
page_frame_paragraph_is_page: true,
inline_invalid_relation_is_baseline: true,
picture_lines_fit_pictures: true,
balance_section_columns: true,
sum_paragraph_spacing: true,
widow_control: false,
}
}
/// A preset's values (a custom profile starts from Word's style).
pub fn of(preset: OdfPreset) -> OdfProfile {
match preset {
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
}
}
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
/// finite and bounded).
pub fn effective(&self) -> OdfProfile {
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
match self.preset {
OdfPreset::Custom => OdfProfile {
page_margin: length(self.page_margin, 0.0),
header_min_height: length(self.header_min_height, 0.0),
column_gap: length(self.column_gap, 0.0),
hairline: length(self.hairline, 0.05),
..self.clone()
},
p => OdfProfile::of(p),
}
}
/// What the layout needs to know of it for a document opened from ODF.
pub fn layout(&self) -> OdfLayout {
let e = self.effective();
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
}
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's
/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and
/// the profile's choices that are made as the document is laid out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// See [`OdfProfile::picture_lines_fit_pictures`].
pub picture_lines_fit_pictures: bool,
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit
/// stops are held from the margin either way; the default ones are counted from the indent.
pub tabs_from_indent: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_and_custom_values() {
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
assert_eq!(OdfPreset::from_id("google"), None);
// Word's style by its id, and by the id it had before.
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
}
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
// Saved and taken by `file.options` as its id.
for p in OdfPreset::ALL {
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
}
// A preset's values apply whatever the profile holds.
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
assert_eq!(lo.effective(), OdfProfile::libreoffice());
// A custom profile's own, bounded.
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
let e = custom.effective();
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
}
}
+2 -3
View File
@@ -23,13 +23,12 @@ mod para_props;
mod table_props;
pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor,
};
#[cfg(test)]
+2 -41
View File
@@ -291,10 +291,6 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text
/// stresses words; `Some(EmphasisMark::None)` turns off one a style sets.
#[serde(skip_serializing_if = "Option::is_none")]
pub emphasis: Option<EmphasisMark>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -385,41 +381,6 @@ pub struct EastAsianLayout {
pub vert_compress: Option<bool>,
}
/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis
/// mark.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum EmphasisMark {
#[default]
None,
/// A dot above.
Dot,
/// A comma (sesame) above.
Comma,
/// A hollow circle above.
Circle,
/// A dot below.
UnderDot,
}
impl EmphasisMark {
pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot];
/// OOXML `w:em` value.
pub fn ooxml(self) -> &'static str {
match self {
EmphasisMark::None => "none",
EmphasisMark::Dot => "dot",
EmphasisMark::Comma => "comma",
EmphasisMark::Circle => "circle",
EmphasisMark::UnderDot => "underDot",
}
}
/// The mark an OOXML `w:em` value names; unknown values are none.
pub fn from_ooxml(v: &str) -> EmphasisMark {
EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default()
}
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -505,7 +466,7 @@ impl CharProps {
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis);
drop_same_fields!(self, base; web_hidden, snap_to_grid);
}
fn minus_script_style(&mut self, base: &CharProps) {
@@ -536,7 +497,7 @@ impl CharProps {
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis);
overlay_fields!(self, patch; web_hidden, snap_to_grid);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
-5
View File
@@ -183,11 +183,6 @@ impl Borders {
pub fn any_visible(&self) -> bool {
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
}
/// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps
/// its room in LibreOffice's layout).
pub fn takes_room(&self) -> bool {
self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0)
}
/// Apply every `Some` side of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &Borders) {
if patch.top.is_some() {
-38
View File
@@ -158,12 +158,6 @@ pub struct TableProps {
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
/// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and
/// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
}
/// Where a floating table sits; the text after it wraps around it.
@@ -300,38 +294,6 @@ pub struct CellProps {
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
/// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has
/// none).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<Box<CellPicture>>,
}
/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched
/// over the cell, and cut off at the cell's edges.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CellPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Its own size, points.
pub size: [f32; 2],
pub repeat: PictureRepeat,
/// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right
/// (bottom) edge.
pub align: [f32; 2],
}
/// How a background picture fills its area (ODF §20.348 `style:repeat`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PictureRepeat {
/// Once, at its own size.
#[default]
None,
/// Repeated across the area.
Tile,
/// Stretched over the area.
Stretch,
}
impl CellProps {
-65
View File
@@ -41,9 +41,6 @@ pub struct ResolvedChar {
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// A mark over or under each character (Font › Emphasis mark).
#[serde(default)]
pub emphasis: crate::props::EmphasisMark,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
@@ -100,7 +97,6 @@ impl ResolvedChar {
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
emphasis: c.emphasis.unwrap_or_default(),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
@@ -182,10 +178,6 @@ pub struct ResolvedPara {
pub tabs: Vec<TabStop>,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
#[serde(default)]
pub padding: [f32; 4],
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
@@ -273,10 +265,6 @@ impl ResolvedPara {
tabs,
shading: p.shading,
borders: p.borders.filter(Borders::any_visible),
padding: p.borders.map_or([0.0; 4], |b| {
[b.top, b.left, b.bottom, b.right]
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
}),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
@@ -335,38 +323,6 @@ impl StyleSheet {
(pp, cp)
}
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
/// that the chain doesn't set. Formats without table styles state these directly.
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
let mut with = self.default_para.clone();
with.overlay(&ParaProps { style: None, ..table.clone() });
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
diff_patch(&with, &without)
}
/// What a table style's run formatting `table` changes for a run of character style
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
/// chains are applied over it (ECMA-376 §17.7.2).
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
let chain = |defaults: CharProps| {
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
if let Some(cs) = run_style {
for s in self.chain(cs) {
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
cp.overlay(&s.chr);
}
}
}
cp
};
let mut with = self.default_chr.clone();
with.overlay(&CharProps { style: None, ..table.clone() });
diff_patch(&chain(with), &chain(self.default_chr.clone()))
}
/// Resolve a paragraph's properties.
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
self.resolve_para_in(p, None)
@@ -431,16 +387,6 @@ impl StyleSheet {
}
}
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
/// kind), as a set of only those; empty when serialisation fails.
fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, without: &T) -> T {
let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else {
return T::default();
};
let patch: serde_json::Map<String, serde_json::Value> = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect();
serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -578,15 +524,4 @@ mod tests {
assert_eq!(p.tabs.len(), 3);
assert_eq!(p.tabs[0].pos, 100.0);
}
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
#[test]
fn resolved_para_without_padding_reads() {
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
let mut v = serde_json::to_value(&p).unwrap();
v.as_object_mut().unwrap().remove("padding");
let back: ResolvedPara = serde_json::from_value(v).unwrap();
assert_eq!(back.padding, [0.0; 4]);
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
}
}
-19
View File
@@ -733,13 +733,6 @@ pub struct SectionProps {
/// Distance from the page edge to the header / footer.
pub header: f32,
pub footer: f32,
/// The space kept between a header and the body, and between the body and a footer, points
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
/// its spacing, so it only moves the body once content and spacing reach past the margin.
#[serde(skip_serializing_if = "is_zero")]
pub header_gap: f32,
#[serde(skip_serializing_if = "is_zero")]
pub footer_gap: f32,
pub gutter: f32,
/// Mirror margins (inside/outside) — set from the document settings.
pub columns: Columns,
@@ -779,11 +772,6 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub balance_columns: Option<bool>,
}
impl Default for SectionProps {
@@ -800,8 +788,6 @@ impl Default for SectionProps {
margin_right: 72.0,
header: 36.0,
footer: 36.0,
header_gap: 0.0,
footer_gap: 0.0,
gutter: 0.0,
columns: Columns::default(),
headers: HeaderSet::default(),
@@ -821,15 +807,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
balance_columns: None,
}
}
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
-10
View File
@@ -46,10 +46,6 @@ pub struct CustomProp {
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
/// a document property: written back as one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub odf_user_field: bool,
}
/// Document properties (File › Info).
@@ -166,11 +162,6 @@ pub struct Settings {
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out, with the choices it leaves open made as the user's profile made them
/// (see [`crate::odf_profile`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<crate::odf_profile::OdfLayout>,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
@@ -316,7 +307,6 @@ impl Default for Settings {
zoom: None,
remove_personal_info: false,
remove_date_time: false,
odf: None,
}
}
}
-34
View File
@@ -232,40 +232,6 @@ impl PartSet {
}
}
/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give
/// their left/right edges at the table's sides and `inside_v` between cells; column parts their
/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their
/// four edges.
pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders {
let mut out = Borders::default();
for (part, b) in parts {
let b = *b;
let mapped = if part.is_row() {
Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
}
} else if part.is_column() {
Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
}
} else {
Borders { between: None, inside_v: None, ..b }
};
out.overlay(&mapped);
}
out
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
+4 -6
View File
@@ -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
View File
@@ -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 });
+7 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+2 -14
View File
@@ -25,23 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp {
name: name.to_string(),
kind: kind.to_string(),
value: v.text(),
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp {
name: name.to_string(),
kind: "raw".into(),
value: xml,
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
};
out.push(prop);
}
+30 -38
View File
@@ -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);
}
@@ -254,7 +250,6 @@ impl PropCtx {
"w:cs" => c.cs = Some(on_off(k)),
"w:caps" => c.caps = Some(on_off(k)),
"w:smallCaps" => c.small_caps = Some(on_off(k)),
"w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()),
"w:strike" => c.strike = Some(on_off(k)),
"w:dstrike" => c.double_strike = Some(on_off(k)),
"w:outline" => c.outline = Some(on_off(k)),
@@ -287,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 {
@@ -366,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();
@@ -481,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].
@@ -540,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),
@@ -646,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),
@@ -683,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() {
@@ -788,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) {
+2 -2
View File
@@ -289,10 +289,10 @@ impl Reader<'_> {
}
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
let (mut sp, refs) = sectpr(&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 {
+63 -1
View File
@@ -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 ----
+6 -7
View File
@@ -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(())
}
+2 -4
View File
@@ -294,10 +294,8 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels,
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
// VML has no centred fill (an ODT page background's): stretched to the page.
let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() };
if !kind.is_empty() {
fa.push(("type", kind));
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
-4
View File
@@ -108,10 +108,6 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
}
toggle(w, "w:rtl", c.rtl);
toggle(w, "w:cs", c.cs);
// Emphasis mark (§17.3.2.12), after `w:cs` and before `w:lang`.
if let Some(m) = c.emphasis {
w.val("w:em", m.ooxml());
}
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
if let Some(l) = &c.lang {
lang_attrs.push(("w:val", l));
+1 -1
View File
@@ -249,7 +249,7 @@ impl Writer<'_> {
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object())
if let Some(src) = ole.as_deref()
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
+1 -2
View File
@@ -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)]);
}
+4 -5
View File
@@ -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();
-48
View File
@@ -1,48 +0,0 @@
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::EmphasisMark;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(xml.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect()
}
#[test]
fn emphasis_marks_are_read_and_saved() {
let run = |t: &str, em: &str| format!(r#"<w:r><w:rPr><w:cs/>{em}<w:lang w:val="ja-JP"/></w:rPr><w:t>{t}</w:t></w:r>"#);
let body = format!(
"<w:p>{}{}{}{}{}{}</w:p>",
run("a", r#"<w:em w:val="dot"/>"#),
run("b", r#"<w:em w:val="comma"/>"#),
run("c", r#"<w:em w:val="circle"/>"#),
run("d", r#"<w:em w:val="underDot"/>"#),
run("e", r#"<w:em w:val="none"/>"#),
run("f", r#"<w:em w:val="sparkle"/>"#),
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
// An unknown mark is none (and the run with it joins the one before, now alike).
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
assert_eq!(marks(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
// In its place: after w:cs, before w:lang.
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
}
+1 -64
View File
@@ -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.
+1 -2
View File
@@ -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 {
-1
View File
@@ -13,7 +13,6 @@ mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
mod emphasis_marks;
mod fixtures;
mod glossary;
mod heading_levels;
+2 -3
View File
@@ -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}");
}
+12 -13
View File
@@ -95,7 +95,6 @@ fn every_char_prop_round_trips() {
scale: Some(150.0),
position: Some(3.0),
kern: Some(8.0),
emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma),
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
outline: Some(true),
shadow: Some(true),
@@ -249,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,
@@ -266,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,
@@ -275,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();
@@ -568,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,
@@ -621,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 {
@@ -1709,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()))]);
@@ -1941,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();
+1 -1
View File
@@ -32,7 +32,7 @@ fn sample() -> Document {
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = 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()))]);
-1
View File
@@ -70,7 +70,6 @@ Home|Font|Character Shading|format.shading
Home|Font|Font Dialog|format.fontDialog
Home|Font|Double Strikethrough|format.doubleStrikethrough
Home|Font|Small Caps|format.smallCaps
Home|Font|Emphasis Mark|format.emphasisMark
Home|Font|All Caps|format.allCaps
Home|Font|Hidden|format.hidden
Home|Font|Emboss|format.emboss
+2 -5
View File
@@ -194,15 +194,12 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) ->
Ok(out)
}
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
/// saving writes that back with the look WordCraft doesn't model.
/// Draw the chart of `o` again at its current size (after a resize), if it has a model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
let source = graphic.source.clone();
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
*graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h));
}
}
+1 -157
View File
@@ -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
@@ -66,47 +65,6 @@ fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option
update_generated(s)
}
/// Most paragraphs a list held as a field around its entries spans.
const MAX_WRAPPED: usize = 10_000;
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
/// field that starts at body block `i` and runs over its entries: its field code and the block
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
.then(|| (k, off, t.to_string()))
}
_ => None,
})?;
// Only when it starts the paragraph: text before it is the document's, not the list's.
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
return None;
}
// Where it ends: the field end that closes it, nested fields counted from its own start (a
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
let mut depth = 0usize;
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
let Some(q) = b.as_para() else { continue };
for (at, o) in q.objects_with_offsets().skip(if j == i { k } else { 0 }) {
match o {
InlineObject::FieldStart { .. } => depth += 1,
InlineObject::FieldEnd => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((instr, j, at + wordcraft_doc::para::OBJ.len_utf8()));
}
}
_ => {}
}
}
}
None
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => {
@@ -194,43 +152,6 @@ pub(super) const ENTRY_STYLE: &str = "ListEntry";
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
let mut i = 0;
while i < s.doc.body.len() {
// A list held as a field around its entries (as Word and ODT files hold them): made the
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
// What follows its closing field end, and its first paragraph's properties and section
// break, are the document's and stay.
if let Some((instr, last, end)) = wrapped_list(s, i) {
// The entries between its first and last paragraphs go.
for _ in i.saturating_add(1)..last {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
// Its first paragraph keeps only the field; its last (or, ending in its first, the
// same) loses what is up to and through the field end, and goes when nothing is left
// of it and it ends no section.
let first_end = if last > i {
let q = s.doc.para_mut(StoryRef::Body, &Path::top(i + 1))?;
q.delete(0, end)?;
if q.is_empty() && q.section.is_none() {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
s.doc.para_mut(StoryRef::Body, &Path::top(i))?.len()
} else {
end
};
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
p.delete(0, first_end)?;
p.insert_object(
0,
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)?;
// What followed the field end in its own paragraph comes after the list, in a
// paragraph of its own that takes the section break.
let field = wordcraft_doc::para::OBJ.len_utf8();
if p.len() > field {
let rest = p.split_off(field)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
}
}
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
let Some(kind) = kind else {
i += 1;
@@ -301,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,
@@ -411,80 +332,3 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
/// ends at its own field end, paragraphs below, not in its first paragraph.
#[test]
fn a_wrapped_list_ends_at_its_own_field_end() {
let mut d = Document::from_text("Old one\nOld two\nAfter");
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0).map(|(instr, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
/// in paragraph `last` at byte `at`.
fn wrapped(text: &str, first: usize, last: usize, at: usize) -> Session {
let mut d = Document::from_text(text);
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(last)) {
p.insert_object(at, InlineObject::FieldEnd, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(first)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
}
Session::new(d)
}
fn texts(s: &Session) -> Vec<String> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()
}
/// What follows a wrapped list's closing field end, in its last paragraph or in its only one,
/// stays, after the list; the last paragraph's section break stays too.
#[test]
fn what_follows_a_wrapped_lists_end_stays() {
// Ending in a later paragraph, with text after the end and a section break.
let mut s = wrapped("Old one\nOld two\nOld three. After\nBelow", 0, 2, "Old three.".len());
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(2)) {
p.section = Some(Box::default());
}
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.props.indent_left = Some(9.0);
}
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.iter().all(|t| !t.contains("Old")), "{t:?}");
assert!(t.iter().any(|t| t == " After"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
let after = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.plain_text() == " After");
assert!(after.is_some_and(|p| p.section.is_some()), "the section break went");
let first = s.doc.body.first().and_then(|b| b.as_para());
assert!(first.is_some_and(|p| p.props.indent_left == Some(9.0) && list_kind(p).is_some()), "{first:?}");
// An empty last paragraph without a section break goes.
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
update_generated(&mut s).unwrap();
// (The field's paragraph shows nothing: there are no sources to list.)
assert_eq!(texts(&s), ["", "Below"]);
// Ending in its own paragraph: what follows comes after the list.
let mut s = wrapped("Old entry. Then this\nBelow", 0, 0, "Old entry.".len());
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.first().is_some_and(|t| !t.contains("Old")), "{t:?}");
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
}
}
+4 -7
View File
@@ -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();
+9 -11
View File
@@ -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
View File
@@ -880,7 +880,6 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
};
*kind = new_kind;
*freeform = geometry;
extra.odf_geometry = None;
*fill = None;
let [_, _, raw_w, raw_h] = found.bounds();
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
+7 -10
View File
@@ -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
}
@@ -260,19 +259,19 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)?
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
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),
@@ -346,9 +345,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params(
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
));
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
@@ -383,7 +380,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
+1 -20
View File
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
use wordcraft_doc::section::SectionProps;
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, 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 -104
View File
@@ -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::*;
+1 -19
View File
@@ -138,17 +138,6 @@ pub fn specs() -> Vec<CommandSpec> {
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
// Font › Emphasis mark: a mark over (or under) each character.
CommandSpec::new("format.emphasisMark", "Emphasis Mark", "Home › Font › Font", |s, v| {
use wordcraft_doc::props::EmphasisMark;
let name = p::str(v, "mark").unwrap_or("dot");
let mark = EmphasisMark::ALL
.into_iter()
.find(|m| m.ooxml().eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| CmdError::Params(format!("unknown emphasis mark `{name}` (none, dot, comma, circle, underDot)")))?;
apply(s, &|c| c.emphasis = Some(mark))
})
.params(r#"{"mark"?: "none" | "dot" | "comma" | "circle" | "underDot"}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
@@ -877,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),
@@ -899,7 +882,6 @@ pub fn state(s: &Session) -> Value {
"caps": all.iter().all(|r| r.caps),
"hidden": all.iter().all(|r| r.hidden),
"spacing": same(&|r| json!(r.spacing)),
"emphasisMark": same(&|r| json!(r.emphasis.ooxml())),
// Kerning for fonts: the smallest kerned size, points; 0 = off.
"kern": same(&|r| json!(r.kern)),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
+3 -34
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
use super::references::{FieldCode, 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.
-22
View File
@@ -83,28 +83,6 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
// text or out of it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
out.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
i += 1;
if q != '\'' {
out.push(q);
} else if chars.get(i) == Some(&'\'') {
out.push('\'');
i += 1;
} else {
break;
}
}
continue;
}
// `am/pm` (any case) is one token: AM or PM.
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().eq_ignore_ascii_case("am/pm")) {
out.push_str(if hh < 12 { "AM" } else { "PM" });
+1 -2
View File
@@ -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;
}
-27
View File
@@ -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();
@@ -349,18 +334,6 @@ fn symbol_takes_its_own_font() {
assert!(s.run("insert.symbol", &json!({"char": ""})).is_err());
}
#[test]
fn date_pictures_keep_quoted_text() {
// Text in single quotes is shown as it is (Word's date-time picture); two quotes are one.
assert_eq!(format_date_parts("H' o''clock'", 2026, 10, 21, (8, 0, 0), 0), "8 o'clock");
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
// An unclosed quote runs to the end.
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
}
#[test]
fn date_pictures() {
// Word accepts either case for the day and year instructions.
+6 -7
View File
@@ -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);
}
+11 -8
View File
@@ -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)?;
+10 -8
View File
@@ -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,
})
-23
View File
@@ -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]
+1 -3
View File
@@ -309,10 +309,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
if let InlineObject::Shape { kind: k, extra, .. } = o {
if let InlineObject::Shape { kind: k, .. } = o {
*k = kind;
// A geometry kept from a file is no longer this shape's.
extra.odf_geometry = None;
}
})
})
+1 -50
View File
@@ -5,7 +5,6 @@
use serde_json::{Value, json};
use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p};
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
/// Options panes, in the order the page lists them (the `pane` param of `file.options`).
pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"];
@@ -23,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -146,7 +145,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())),
}
let check_spelling = bool_param(v, "checkSpelling")?;
let odf = odf_param(v, &s.prefs.odf)?;
let add = words_param(v, "addWords")?;
let remove = words_param(v, "removeWords")?;
@@ -159,7 +157,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|| !flag_values.is_empty()
|| !mark_values.is_empty()
|| check_spelling.is_some()
|| odf.is_some()
|| !add.is_empty()
|| !remove.is_empty();
if let Some(name) = author {
@@ -191,10 +188,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
s.view.proofing = b;
s.prefs.check_spelling = b;
}
// ODF files opened from now on fill in what their standard leaves open this way.
if let Some(o) = odf {
s.prefs.odf = o;
}
for w in &add {
wordcraft_proof::add_word(w);
}
@@ -240,9 +233,6 @@ pub fn current(s: &Session) -> Value {
"dictionary": wordcraft_proof::user_dictionary(),
"dictionariesFolder": pr.dictionaries_folder,
"userTemplatesFolder": pr.user_templates_folder,
// How ODF files fill in what the standard leaves open: the chosen preset, the values
// that apply, and the custom values kept for when the preset is custom.
"odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf},
"dictionaries": wordcraft_proof::installed_dictionaries()
.into_iter()
.map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
@@ -250,45 +240,6 @@ pub fn current(s: &Session) -> Value {
})
}
/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`.
/// A case value given without a preset makes the profile custom; the values are kept whatever
/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches;
/// colours `RRGGBB`.
fn odf_param(v: &Value, current: &OdfProfile) -> Result<Option<OdfProfile>, CmdError> {
let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) };
let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?;
let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?;
let known: Vec<String> = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default();
let mut values = false;
for (k, x) in obj {
if !known.contains(k) {
return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", "))));
}
if let Some(n) = x.as_f64().filter(|_| k != "preset")
&& !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n))
{
return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH)));
}
let x = if k == "preset" {
let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?;
json!(p)
} else {
values = true;
x.clone()
};
if let Some(m) = merged.as_object_mut() {
m.insert(k.clone(), x);
}
}
if values
&& !obj.contains_key("preset")
&& let Some(m) = merged.as_object_mut()
{
m.insert("preset".into(), json!(OdfPreset::Custom));
}
serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}")))
}
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
+2 -360
View File
@@ -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 -2
View File
@@ -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)
+3 -4
View File
@@ -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 -2
View File
@@ -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);
+5 -6
View File
@@ -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;
}
+1 -2
View File
@@ -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));
}
}
+2 -2
View File
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
#[test]
fn formatting_restriction_refuses_attach_template_update() {
let mut t = Document::from_text("Template.");
if let Some(st) = 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}");
+30 -56
View File
@@ -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() {
+46 -259
View File
@@ -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| {
@@ -335,12 +181,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
// after one that sets a property shows the new value.
let mut core = s.doc.core.clone();
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
// REF before the SET shows the last value set, as Word's bookmark does after an update.
let last_set = set_values(s, &paths);
let mut set: std::collections::HashMap<String, String> = Default::default();
for (story, path) in paths {
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
continue;
@@ -372,14 +213,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let f = FieldCode::parse(instr);
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
let r = if name == "REF" {
if has_switch(instr, 'p') {
None
} else {
bookmark_text(&s.doc, target).or_else(|| {
let key = target.to_lowercase();
set.get(&key).or_else(|| last_set.get(&key)).cloned()
})
}
if !has_switch(instr, 'p') { bookmark_text(&s.doc, target) } else { None }
} else if !has_switch(instr, 'p') {
pages.get(target).map(|(num, page_fmt)| {
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
@@ -392,14 +226,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
updates.push((k, if name == "REF" { text_result(instr, r) } else { r }));
}
}
// A value given a name (the layout draws nothing for it; its result stays).
"SET" => {
if let Some((n, v)) = set_args(instr)
&& set.len() < MAX_SET
{
set.insert(n, v);
}
}
// The summary properties; a quoted text after the name replaces the property.
"TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => {
if let Some(v) = new_property_value(instr) {
@@ -408,16 +234,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
updates.push((k, text_result(instr, summary_property(&core, &name).to_string())));
}
"LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))),
// The user's name and initials (File › Options › General); a quoted text after
// the name is shown instead.
"USERNAME" | "USERINITIALS" => {
let v = match new_property_value(instr) {
Some(v) => v.to_string(),
None if name == "USERNAME" => s.author.clone(),
None => s.user_initials(),
};
updates.push((k, text_result(instr, v)));
}
"NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))),
"NUMCHARS" => {
let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count();
@@ -500,32 +316,3 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
}
/// Most names SET fields give values.
const MAX_SET: usize = 10_000;
/// A SET field's name (lower case: names are a bookmark's, which ignore case) and value.
fn set_args(instr: &str) -> Option<(String, String)> {
let f = FieldCode::parse(instr);
let name = f.args.first().map(|n| n.trim()).filter(|n| !n.is_empty() && n.chars().count() <= 40)?;
let value: String = f.args.get(1..).map(|v| v.join(" ")).unwrap_or_default().chars().take(MAX_SET).collect();
Some((name.to_lowercase(), value))
}
/// The value each name has after the last SET giving it one, in the stories at `paths`.
fn set_values(s: &Session, paths: &[(StoryRef, Path)]) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for (story, path) in paths {
let Some(p) = s.doc.para(*story, path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, .. } = o
&& wordcraft_layout::fields::field_name(instr) == "SET"
&& let Some((n, v)) = set_args(instr)
&& out.len() < MAX_SET
{
out.insert(n, v);
}
}
}
out
}
+1 -16
View File
@@ -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())
}
-386
View File
@@ -327,42 +327,6 @@ fn document_information_fields_show_the_properties() {
assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]);
}
/// SET gives a name a value (and is drawn as nothing); REF shows it, a REF before the SET the value it
/// ends up with (an ODT's variables open as SET and REF).
#[test]
fn set_gives_a_value_that_ref_shows() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = [
r#"SET who "Ada Lovelace""#,
"REF who",
r"REF WHO \* Upper",
"REF later",
"SET later Bob",
r#"SET who "Grace""#,
"REF who",
"SET",
"REF nothing",
];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!([&got[1], &got[2], &got[3], &got[6]], ["Ada Lovelace", "ADA LOVELACE", "Bob", "Grace"]);
}
/// USERNAME and USERINITIALS show the user's name and initials from File › Options (an ODT's
/// `text:author-name` opens as USERNAME); a quoted text after the name is shown instead.
#[test]
fn user_name_fields_show_the_users_name() {
let doc = wordcraft_doc::Document::from_text("one");
let codes = ["USERNAME", "USERINITIALS", r"USERNAME \* Upper", r#"USERNAME "Someone Else""#];
let (mut s, hdr) = header_fields_session(doc, &codes);
s.author = "Ada Lovelace".into();
s.initials = String::new();
s.run("references.updateFields", &json!({})).unwrap();
let got: Vec<String> = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["Ada Lovelace", "AL", "ADA LOVELACE", "Someone Else"]);
}
/// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property
/// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value.
#[test]
@@ -547,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"]);
}
+5 -5
View File
@@ -112,7 +112,7 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
let st = build_new(s, v)?;
let id = st.id.clone();
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() {
+12 -13
View File
@@ -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,
+19 -9
View File
@@ -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);
}
+2 -13
View File
@@ -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);
+5 -11
View File
@@ -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();
+2 -3
View File
@@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> {
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
if read && !s.view.read_mode {
// 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}))
}
+16 -66
View File
@@ -1,7 +1,6 @@
//! File format dispatch by extension. Format crates plug in here.
use wordcraft_doc::Document;
use wordcraft_doc::odf_profile::OdfProfile;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
@@ -23,8 +22,6 @@ pub const OPEN_EXTS: &[&str] = &[
"mhtml",
"rtf",
"odt",
"ott",
"fodt",
"tex",
"latex",
"ltx",
@@ -33,8 +30,7 @@ pub const OPEN_EXTS: &[&str] = &[
"json",
];
/// Formats WordCraft saves (Save As).
pub const SAVE_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "ott", "fodt", "tex", "png", "json", "xml"];
pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"];
/// Magic number for ZIP package signatures (used by .docx/.docm/.dotx/.dotm).
const ZIP_SIG: &[u8] = b"PK\x03\x04";
@@ -133,25 +129,9 @@ pub(crate) fn canonical(_path: &std::path::Path) -> Option<std::path::PathBuf> {
None
}
/// Can files with this extension be saved with a password (Word's packages, ODT)?
/// Can files with this extension be saved with a password (Word's packages)?
pub fn can_encrypt(name: &str) -> bool {
let ext = ext_of(name);
is_word_package(&ext) || ext == "odt" || ext == "ott"
}
/// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or
/// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`]
/// recognise them.
fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result<Document, String> {
let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?;
let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e {
wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD),
other => other.to_string(),
})?;
match crate::io_ext::open("odt", &plain, page, odf) {
Some(r) => r,
None => Err("ODT: no reader".into()),
}
is_word_package(&ext_of(name))
}
/// Finish a document just read from a file: never leave it empty, and bring its unlocked
@@ -188,7 +168,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> {
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default())
}
/// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s
@@ -199,17 +179,10 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro
}
/// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper
/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills
/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]).
pub fn open_bytes_in(
name: &str,
bytes: &[u8],
password: Option<&str>,
region: &wordcraft_geom::Region,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
/// (see [`region_page`]); a document with its own page size keeps it.
pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
let page = region_page(region);
let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?;
let (doc, encrypted) = read_bytes(name, bytes, password, &page)?;
load_embedded_fonts(&doc);
Ok((doc, encrypted))
}
@@ -227,24 +200,13 @@ fn load_embedded_fonts(doc: &Document) {
}
}
fn read_bytes(
name: &str,
bytes: &[u8],
password: Option<&str>,
page: &wordcraft_doc::SectionProps,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
let ext = ext_of(name);
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
opened(&mut doc);
return Ok((doc, true));
}
if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) {
let mut doc = open_encrypted_odt(bytes, password, page, odf)?;
opened(&mut doc);
return Ok((doc, true));
}
if ext.is_empty() || signature_contradicts(&ext, bytes) {
return open_by_content(name, &ext, bytes, password, page);
}
@@ -261,7 +223,7 @@ fn read_bytes(
d.normalize_runs();
d
}
other => match crate::io_ext::open(other, bytes, page, odf) {
other => match crate::io_ext::open(other, bytes, page) {
Some(r) => r?,
None => return open_by_content(name, other, bytes, password, page),
},
@@ -435,11 +397,10 @@ fn open_by_content(
} else if is_word_xml(bytes) {
(wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false)
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
(opened?, false)
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
let opened =
crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
(opened?, false)
} else if wordcraft_formats::txt::is_text(bytes) {
(wordcraft_formats::txt::import_on(bytes, page), false)
@@ -479,7 +440,6 @@ pub fn save_bytes_opts(name: &str, doc: &Document, opts: &ExportOptions) -> Resu
pub fn save_bytes_with(name: &str, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let bytes = save_bytes_opts(name, doc, opts)?;
match password {
Some(pw) if matches!(ext_of(name).as_str(), "odt" | "ott") => wordcraft_formats::odt::encrypt(&bytes, pw),
Some(pw) if can_encrypt(name) => wordcraft_docx::encrypt(&bytes, pw).map_err(|e| e.to_string()),
_ => Ok(bytes),
}
@@ -492,17 +452,12 @@ pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
/// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> {
open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default())
open_path_in(path, password, &wordcraft_geom::Region::default())
}
/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]).
/// [`open_path_with`] in `region` (see [`open_bytes_in`]).
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_in(
path: &std::path::Path,
password: Option<&str>,
region: &wordcraft_geom::Region,
odf: &OdfProfile,
) -> Result<(Document, bool), String> {
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
let bytes = read_document(path)?;
let name = path.to_string_lossy();
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
@@ -514,7 +469,7 @@ pub fn open_path_in(
opened(&mut doc);
return Ok((doc, false));
}
open_bytes_in(&name, &bytes, password, region, odf)
open_bytes_in(&name, &bytes, password, region)
}
/// Largest document file opened by path.
@@ -851,12 +806,7 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result
}
#[cfg(target_arch = "wasm32")]
pub fn open_path_in(
path: &std::path::Path,
password: Option<&str>,
_region: &wordcraft_geom::Region,
_odf: &OdfProfile,
) -> Result<(Document, bool), String> {
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
open_path_with(path, password)
}
+2 -38
View File
@@ -4,53 +4,22 @@ use wordcraft_doc::Document;
/// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML)
/// start on `page`.
pub fn open(
ext: &str,
bytes: &[u8],
page: &wordcraft_doc::SectionProps,
odf: &wordcraft_doc::odf_profile::OdfProfile,
) -> Option<Result<Document, String>> {
pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
match ext {
"docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())),
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
other => wordcraft_formats::import_with(other, bytes, page, odf).map(|r| {
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
r.map(|mut doc| {
crate::io::fit_pictures(&mut doc);
draw_charts(&mut doc);
doc
})
}),
}
}
/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the
/// document's theme colours, at their size.
fn draw_charts(doc: &mut Document) {
use wordcraft_doc::para::InlineObject;
/// Groups nest at most this deep here (the readers flatten deeper ones).
const DEPTH: usize = 16;
fn undrawn(o: &InlineObject, depth: usize) -> bool {
match o {
InlineObject::Graphic { graphic, .. } => graphic.chart.is_some() && graphic.items.is_empty(),
InlineObject::Group { children, .. } if depth < DEPTH => children.iter().any(|c| undrawn(&c.obj, depth + 1)),
_ => false,
}
}
fn draw(o: &mut InlineObject, theme: &[wordcraft_doc::Rgb], depth: usize) {
match o {
InlineObject::Group { children, .. } if depth < DEPTH => children.iter_mut().for_each(|c| draw(&mut c.obj, theme, depth + 1)),
o if undrawn(o, depth) => crate::cmd::chart::redraw(o, theme),
_ => {}
}
}
let theme = doc.settings.theme_colors.clone();
// (Drawing them doesn't change the document: markup kept as read for them still is.)
doc.fill_objects(&|o| undrawn(o, 0), &mut |o| draw(o, &theme, 0));
}
/// Save to a format other than plain text / JSON.
pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option<Result<Vec<u8>, String>> {
// .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects.
@@ -62,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,
@@ -80,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
View File
@@ -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 {
+1 -44
View File
@@ -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,
@@ -59,17 +56,6 @@ pub struct Prefs {
/// in Word). A damaged saved value reads as the defaults.
#[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")]
pub autocorrect: crate::cmd::tools::AutoCorrectPrefs,
/// How OpenDocument files fill in what their standard leaves to the application: WordCraft's
/// choices, LibreOffice's, or the user's own (File › Options › Advanced, `file.options`'s
/// `odf`; see `docs/odf-interpretation.md`). A damaged saved value reads as WordCraft's.
#[serde(deserialize_with = "lenient_odf")]
pub odf: wordcraft_doc::odf_profile::OdfProfile,
}
/// A saved ODF profile that doesn't read (an older or damaged one) is WordCraft's.
fn lenient_odf<'de, D: serde::Deserializer<'de>>(d: D) -> Result<wordcraft_doc::odf_profile::OdfProfile, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(v).unwrap_or_default())
}
impl Default for Prefs {
@@ -88,14 +74,12 @@ 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,
detect_language: true,
default_language: None,
autocorrect: Default::default(),
odf: Default::default(),
}
}
}
@@ -116,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");
}
}
+5 -88
View File
@@ -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(&params, &lay, s.current_pages(&lay)).unwrap().len(), sides);
let (bytes, plan) = s.print_pdf(&params).unwrap();
assert_eq!(plan.copies, 2);
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
}
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
}
for params in [json!(null), json!([]), json!(false)] {
assert!(s.print_pdf(&params).is_err());
}
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
}
}
+2 -3
View File
@@ -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."),
-2
View File
@@ -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);
+3 -10
View File
@@ -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) {
-1
View File
@@ -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"))]
{
-1
View File
@@ -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 -369
View File
@@ -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(&reg, &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).
@@ -6967,23 +6764,6 @@ fn inserted_chart_is_saved_and_reopened_with_its_data() {
assert!(first_chart(&s3.doc).is_none());
}
#[test]
fn redrawing_a_chart_keeps_the_chart_as_read() {
// A chart read from a file keeps its original (`source`) through drawing it again (when it
// opens or is resized): saving writes that back while the chart is unchanged.
use std::sync::Arc;
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicKind};
use wordcraft_doc::para::InlineObject;
let spec = wordcraft_doc::chart::ChartSpec::sample(wordcraft_doc::chart::ChartType::Column);
let source = Arc::new(Embedded { xml: format!("{} mime=\"x\" ole=\"false\"/>", Embedded::ODF_OBJECT), refs: Vec::new() });
let graphic = Graphic { kind: GraphicKind::Chart, chart: Some(Arc::new(spec)), source: Some(source.clone()), ..Default::default() };
let mut o = InlineObject::Graphic { w: 300.0, h: 200.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
crate::cmd::chart::redraw(&mut o, &[]);
let InlineObject::Graphic { graphic, .. } = &o else { panic!() };
assert!(!graphic.items.is_empty(), "drawn");
assert_eq!(graphic.source.as_deref(), Some(&*source));
}
#[test]
fn chart_edits_are_one_undo_step_each() {
use wordcraft_doc::chart::{ChartType, LegendPos};
@@ -7187,26 +6967,6 @@ fn password_protected_documents_round_trip() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn changing_a_shape_drops_the_geometry_kept_from_a_file() {
// An ODT custom shape keeps its geometry as read, to save back; made another shape, it isn't
// that shape any more.
let mut s = s();
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
let geometry = |s: &Session| match cmd::objects::selected(s) {
Some((_, wordcraft_doc::InlineObject::Shape { extra, .. })) => extra.odf_geometry.clone(),
o => panic!("{o:?}"),
};
let Some((pos, _)) = cmd::objects::selected(&s) else { panic!() };
let para = s.doc.para_mut(pos.story, &pos.path).unwrap();
if let Some(wordcraft_doc::InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) {
extra.odf_geometry = Some(std::sync::Arc::new(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#.to_string()));
}
assert!(geometry(&s).is_some());
run(&mut s, "shape.change", json!({"kind": "ellipse"}));
assert_eq!(geometry(&s), None);
}
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
#[test]
@@ -7328,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());
@@ -7473,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"] {
@@ -8085,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!({}));
@@ -8878,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.
@@ -9472,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}));
@@ -10115,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!({}));
@@ -10778,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,
@@ -10896,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() })),
@@ -10939,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,
@@ -11533,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());
@@ -11836,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");
@@ -11910,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);
@@ -11920,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");
@@ -11992,93 +11746,6 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
fn emphasis_marks_are_set_and_drawn() {
use wordcraft_doc::props::EmphasisMark;
let mut s = Session::new(wordcraft_doc::Document::from_text("強調する"));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.emphasisMark", json!({"mark": "circle"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(0).emphasis, Some(EmphasisMark::Circle));
assert_eq!(run(&mut s, "format.state", json!({}))["emphasisMark"], "circle");
// One mark per character: hollow circles, drawn as shapes.
let draws = s.layout().pages.first().map(|pg| wordcraft_layout::display::page_display(&s.doc, pg, &Default::default())).unwrap_or_default();
let marks = draws
.iter()
.filter(|d| matches!(d, wordcraft_layout::display::Draw::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, fill: None, .. }))
.count();
assert_eq!(marks, 4);
run(&mut s, "format.emphasisMark", json!({"mark": "none"}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).emphasis, Some(EmphasisMark::None));
assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err());
}
/// File › Options › Advanced › OpenDocument files: a preset, or one case's value (which makes the
/// profile custom); junk is refused; files opened afterwards follow it.
#[test]
fn odf_interpretation_is_an_option_files_follow() {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
let mut s = s();
assert_eq!(s.prefs.odf, OdfProfile::word(), "Microsoft Word's style is the default");
let r = run(&mut s, "file.options", json!({"odf": {"preset": "LibreOffice"}}));
assert_eq!(r["odf"]["preset"], json!("libreoffice"));
assert_eq!(s.prefs.odf.effective(), OdfProfile::libreoffice());
// The two display styles, back and forth; Word's by its earlier id too.
let r = run(&mut s, "file.options", json!({"odf": {"preset": "word"}}));
assert_eq!(r["odf"]["preset"], json!("word"));
assert_eq!(s.prefs.odf.effective(), OdfProfile::word());
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
let r = run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
assert_eq!(r["odf"]["preset"], json!("word"));
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
run(&mut s, "file.options", json!({"odf": {"columnGap": 18.0}}));
assert_eq!((s.prefs.odf.preset, s.prefs.odf.effective().column_gap), (OdfPreset::Custom, 18.0));
for junk in [
json!({"odf": {"nope": 1}}),
json!({"odf": {"columnGap": -1}}),
json!({"odf": {"hairline": 1e9}}),
json!({"odf": 3}),
json!({"odf": {"preset": "google"}}),
] {
assert!(s.run("file.options", &junk).is_err(), "{junk}");
}
assert_eq!(s.prefs.odf.effective().column_gap, 18.0, "a refused change leaves it");
// A tab stop in an indented paragraph of a file that doesn't say how tabs are measured.
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:margin-left="0.5in"><style:tab-stops><style:tab-stop style:position="1in"/></style:tab-stops></style:paragraph-properties></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">a</text:p></office:text></office:body></office:document-content>"#;
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let bytes = z.finish().unwrap().into_inner();
let tab = |s: &Session| {
let (d, _) = crate::io::open_bytes_in("t.odt", &bytes, None, &s.region(), &s.prefs.odf).unwrap();
d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default().first().map(|t| t.pos)
};
run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}}));
assert_eq!(tab(&s), Some(72.0), "WordCraft: from the margin");
run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}}));
assert_eq!(tab(&s), Some(108.0), "LibreOffice: from the indent");
}
#[test]
fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
// Opening an ODT draws its charts (from their data); that changes nothing in the document,
// so the paragraph showing one is still written as read.
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "insert.chart", json!({"type": "column"}));
let first = wordcraft_formats::odt::export(&s.doc).unwrap();
let doc = crate::io::open_bytes("t.odt", &first).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
let mut content = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap();
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
}
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
#[cfg(not(target_arch = "wasm32"))]
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
@@ -12169,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 =
@@ -12184,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

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