mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-04 09:20:15 -06:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ceaf5b80 | ||
|
|
2df2bfc30e | ||
|
|
958867b7e8 | ||
|
|
e3b000b650 | ||
|
|
604ac6ddb2 | ||
|
|
b2734c28f4 | ||
|
|
ed231fd027 | ||
|
|
560565cdd7 | ||
|
|
1350f62bf0 | ||
|
|
688cce4731 | ||
|
|
6ec85141d5 | ||
|
|
2d6dff96cc | ||
|
|
e847b2e5e3 | ||
|
|
fd1c5efe6c | ||
|
|
655b4312d3 | ||
|
|
ba58e70cc8 | ||
|
|
6f62716f08 | ||
|
|
08afab50b6 | ||
|
|
cd6b497c8a | ||
|
|
1340705fa6 | ||
|
|
98da959ceb |
Generated
+20
-19
@@ -6785,7 +6785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6820,7 +6820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6833,11 +6833,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6851,7 +6851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6868,7 +6868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6892,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6925,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6939,7 +6939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chardetng",
|
||||
@@ -6958,7 +6958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6966,7 +6966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -6986,7 +6986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -6998,7 +6998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7006,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7027,7 +7027,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7041,8 +7041,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
@@ -7073,7 +7074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7092,7 +7093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7255,7 +7256,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+18
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
//! The control channel's key file, one rule for the app (which writes it) and the MCP bridge
|
||||
//! (which reads it): `<settings>/control-key.<instance>`. See `docs/control-protocol.md`, Keys.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
|
||||
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
use super::{BTreeMap, CharProps, Document, InlineObject, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
@@ -62,8 +62,8 @@ impl Document {
|
||||
body: vec![para_block(Paragraph::new())],
|
||||
last_section: SectionProps::default(),
|
||||
parts: BTreeMap::new(),
|
||||
styles: StyleSheet::builtin(),
|
||||
numbering: Numbering::default(),
|
||||
styles: Arc::new(StyleSheet::builtin()),
|
||||
numbering: Arc::default(),
|
||||
comments: BTreeMap::new(),
|
||||
revisions: Vec::new(),
|
||||
settings: Settings::default(),
|
||||
|
||||
@@ -1054,7 +1054,7 @@ impl Document {
|
||||
let mut default_style = None;
|
||||
if keep_styles {
|
||||
if src.styles.get("Normal").is_none() {
|
||||
src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut src.styles).upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() });
|
||||
}
|
||||
// Ids given out here are taken too: two styles never get the same new one.
|
||||
let mut given = std::collections::HashSet::new();
|
||||
@@ -1089,7 +1089,7 @@ impl Document {
|
||||
abs_map.insert(a.id, id);
|
||||
let mut a = crate::numbering::AbstractNum { id, ..a.clone() };
|
||||
a.levels.iter_mut().for_each(|l| restyle(&mut l.style));
|
||||
self.numbering.abstracts.push(a);
|
||||
Arc::make_mut(&mut self.numbering).abstracts.push(a);
|
||||
}
|
||||
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut nums = BTreeMap::new();
|
||||
@@ -1099,7 +1099,7 @@ impl Document {
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() };
|
||||
n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style));
|
||||
self.numbering.nums.push(n);
|
||||
Arc::make_mut(&mut self.numbering).nums.push(n);
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
@@ -1138,7 +1138,7 @@ impl Document {
|
||||
}
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
renumber(&mut st.para.numbering);
|
||||
self.styles.upsert(st);
|
||||
Arc::make_mut(&mut self.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
|
||||
@@ -663,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
|
||||
|
||||
#[test]
|
||||
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
|
||||
// the innermost paragraphs come first in the document but the paragraph walk stops short.
|
||||
// The deepest nesting allowed, each table in the first cell of the one around it, ahead of
|
||||
// that cell's paragraph: the innermost paragraphs come first in the document but the
|
||||
// paragraph walk stops short.
|
||||
const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) };
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
|
||||
}
|
||||
for _ in 0..20 {
|
||||
for _ in 1..MAX_TABLE_DEPTH {
|
||||
let mut outer = Table::new(1, 1, 200.0);
|
||||
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
|
||||
cell.blocks.insert(0, Arc::new(Block::Table(t)));
|
||||
@@ -690,19 +692,19 @@ fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
@@ -746,11 +748,11 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
theirs.styles = Arc::new(StyleSheet::empty());
|
||||
Arc::make_mut(&mut theirs.styles).default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
@@ -764,15 +766,15 @@ fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
Arc::make_mut(&mut ours.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
let a = Arc::make_mut(&mut theirs.numbering).abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
Arc::make_mut(&mut theirs.numbering).nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
@@ -812,10 +814,10 @@ fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
@@ -843,8 +845,8 @@ fn adopted_styles_never_become_our_defaults() {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
Arc::make_mut(&mut theirs.styles).upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
|
||||
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
|
||||
//! table cells hold block lists of their own. Blocks are `Arc`-shared, so cloning a document is
|
||||
//! cheap (undo snapshots, handing a frozen copy to the renderer) and edits copy only what they
|
||||
//! touch (`Arc::make_mut`).
|
||||
//! table cells hold block lists of their own. Blocks, the style sheet and the list definitions
|
||||
//! are `Arc`-shared, so cloning a document is cheap (undo snapshots, handing a frozen copy to the
|
||||
//! renderer) and edits copy only what they touch (`Arc::make_mut`).
|
||||
//!
|
||||
//! Positions ([`Pos`]) name a story, a [`Path`] to a paragraph and a byte offset in it. Paths
|
||||
//! compare in document order.
|
||||
@@ -98,8 +98,12 @@ pub struct Document {
|
||||
/// Properties of the last section (OOXML's body-level `w:sectPr`).
|
||||
pub last_section: SectionProps,
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
pub styles: StyleSheet,
|
||||
pub numbering: Numbering,
|
||||
/// Shared, like the blocks: an undo step copies it only when a command changes it (a full
|
||||
/// copy is kilobytes a style, so typing into a document with hundreds of styles cost megabytes
|
||||
/// a step).
|
||||
pub styles: Arc<StyleSheet>,
|
||||
/// Shared like [`Document::styles`].
|
||||
pub numbering: Arc<Numbering>,
|
||||
pub comments: BTreeMap<u32, Comment>,
|
||||
pub revisions: Vec<Revision>,
|
||||
pub settings: Settings,
|
||||
|
||||
+114
-2
@@ -1,5 +1,6 @@
|
||||
//! Which hyperlink targets may leave the document: followed by the OS, or written into an
|
||||
//! exported HTML/Markdown file where a browser would run them.
|
||||
//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the
|
||||
//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser
|
||||
//! would run them.
|
||||
|
||||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading
|
||||
@@ -39,10 +40,121 @@ fn scheme(url: &str) -> Option<String> {
|
||||
(first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`)
|
||||
/// or `#bookmark`. None for any other field, or one with no target.
|
||||
pub fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter().peekable();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\');
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some(arg @ (a, _)) = args.next() {
|
||||
if !is_switch(&arg) {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none.
|
||||
let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@");
|
||||
let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l")
|
||||
&& let Some((v, _)) = value
|
||||
{
|
||||
anchor = Some(v.as_str());
|
||||
}
|
||||
}
|
||||
let url = url.map(str::trim).filter(|u| !u.is_empty());
|
||||
let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty());
|
||||
match (url, anchor) {
|
||||
// `\l` replaces a fragment the address already has, as in Word.
|
||||
(Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes a backslash escapes the next character, as in Word.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling.
|
||||
#[test]
|
||||
fn hyperlink_field_codes_name_their_targets() {
|
||||
for (code, want) in [
|
||||
(r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")),
|
||||
(r#"HYPERLINK \l "heading""#, Some("#heading")),
|
||||
(r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")),
|
||||
(r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")),
|
||||
("HYPERLINK https://example.org/b", Some("https://example.org/b")),
|
||||
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
|
||||
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
|
||||
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
|
||||
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
|
||||
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
|
||||
// Switches that take a value, before or after the address, and those that take none.
|
||||
(r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")),
|
||||
(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")),
|
||||
(r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")),
|
||||
(r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")),
|
||||
(r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")),
|
||||
(r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")),
|
||||
(r#"HYPERLINK \l "bm" \l"#, Some("#bm")),
|
||||
// A bookmark's own `#`, an address that has a fragment, and an empty address.
|
||||
(r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")),
|
||||
(r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")),
|
||||
(r#"HYPERLINK "" \l "x""#, Some("#x")),
|
||||
// No target, or not a `HYPERLINK` field.
|
||||
("HYPERLINK", None),
|
||||
(r#"HYPERLINK "" \o "tip""#, None),
|
||||
(r#"HYPERLINKS "https://example.org/""#, None),
|
||||
(r#"HYPERLINKX "http://example.com""#, None),
|
||||
(r#"HYPERLINK\l "x""#, None),
|
||||
("PAGEREF _Toc1 \\h", None),
|
||||
("ab日本語", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_web_and_mail_links_are_followable() {
|
||||
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
|
||||
|
||||
@@ -71,10 +71,12 @@ pub struct Table {
|
||||
/// Hard limits that keep hostile input from exhausting memory.
|
||||
pub const MAX_ROWS: usize = 32_767;
|
||||
pub const MAX_COLS: usize = 63;
|
||||
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
|
||||
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
|
||||
/// when it is saved, copied or dropped (#1596).
|
||||
pub const MAX_TABLE_DEPTH: usize = 24;
|
||||
/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a
|
||||
/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or
|
||||
/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level
|
||||
/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its
|
||||
/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24.
|
||||
pub const MAX_TABLE_DEPTH: usize = 18;
|
||||
|
||||
impl Table {
|
||||
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
|
||||
|
||||
+12
-11
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod note_conversion;
|
||||
|
||||
@@ -35,8 +36,8 @@ fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let bul_id = d.numbering.add_list(ListKind::Bullet);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let bul_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
|
||||
@@ -60,9 +61,9 @@ fn word_count_includes_numbered_list_labels_but_not_bullets() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].suffix = numbering::LevelSuffix::Nothing;
|
||||
|
||||
let mut p1 = Paragraph::with_text("Apple", CharProps::default());
|
||||
@@ -81,9 +82,9 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
|
||||
#[test]
|
||||
fn word_count_keeps_a_cjk_list_label_one_word() {
|
||||
let mut d = Document::new();
|
||||
let num_id = d.numbering.add_list(ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
|
||||
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
let a = Arc::make_mut(&mut d.numbering).abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
|
||||
a.levels[0].format = section::NumFormat::ChineseCounting;
|
||||
a.levels[0].text = "第%1章".into();
|
||||
a.levels[0].start = 12;
|
||||
@@ -187,7 +188,7 @@ fn parts_and_lists_after_the_largest_ids_take_free_ones() {
|
||||
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
|
||||
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
|
||||
|
||||
let n = &mut d.numbering;
|
||||
let n = Arc::make_mut(&mut d.numbering);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.add_list(ListKind::Bullet);
|
||||
n.abstracts[1].id = u32::MAX;
|
||||
@@ -229,11 +230,11 @@ fn same_content_sees_every_change() {
|
||||
assert!(!d.same_content(&e), "the formatting changed");
|
||||
// The styles changed.
|
||||
let mut e = d.clone();
|
||||
e.styles.get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
Arc::make_mut(&mut e.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
|
||||
assert!(!d.same_content(&e), "a style changed");
|
||||
// The lists changed.
|
||||
let mut e = d.clone();
|
||||
e.numbering.add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut e.numbering).add_list(ListKind::Numbered);
|
||||
assert!(!d.same_content(&e), "a list was added");
|
||||
// The settings changed.
|
||||
let mut e = d.clone();
|
||||
@@ -452,9 +453,9 @@ fn box_para(story: u32, id: u32) -> Blocks {
|
||||
fn adopting_relinks_lists_in_deeply_nested_tables() {
|
||||
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
|
||||
let mut ours = Document::from_text("ours");
|
||||
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let our_list = Arc::make_mut(&mut ours.numbering).add_list(numbering::ListKind::Bullet);
|
||||
let mut theirs = Document::from_text("theirs");
|
||||
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut theirs.numbering).add_list(numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let mut p = Paragraph::with_text("item", CharProps::default());
|
||||
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
|
||||
|
||||
@@ -109,12 +109,13 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
|
||||
ICO.get(i as usize).copied()
|
||||
}
|
||||
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
|
||||
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
|
||||
/// reader maps them.
|
||||
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in
|
||||
/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap
|
||||
/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them.
|
||||
fn brc_style(t: u8) -> BorderStyle {
|
||||
match t {
|
||||
0x00 => BorderStyle::None,
|
||||
0x02 => BorderStyle::Thick,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x06 => BorderStyle::Dotted,
|
||||
0x07 | 0x16 => BorderStyle::Dashed,
|
||||
@@ -125,15 +126,15 @@ fn brc_style(t: u8) -> BorderStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
|
||||
/// 1-point steps for page borders, which never reach a paragraph property).
|
||||
/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz`
|
||||
/// reads (1-point steps for page borders, which never reach a paragraph property).
|
||||
fn brc_width(t: u8, dpt: u8) -> f32 {
|
||||
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
|
||||
if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) }
|
||||
}
|
||||
|
||||
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
|
||||
/// truncated operand is ignored.
|
||||
fn brc80(op: &[u8]) -> Option<Border> {
|
||||
pub(crate) fn brc80(op: &[u8]) -> Option<Border> {
|
||||
let [dpt, ty, ico_index, tail, ..] = op else { return None };
|
||||
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
|
||||
return Some(Border::default());
|
||||
|
||||
@@ -25,7 +25,9 @@ mod stsh;
|
||||
mod table;
|
||||
|
||||
use std::io::{Read, Seek};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::link::hyperlink_target;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::para::NoteKind;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
@@ -248,9 +250,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.core = core;
|
||||
}
|
||||
doc.body.clear();
|
||||
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
|
||||
doc.numbering = list::parse(&table, &fib, &fonts);
|
||||
let sheet: StyleSheet = doc.styles.clone();
|
||||
doc.styles = Arc::new(fmt::stylesheet(&raw_styles, &fonts, standard_fonts));
|
||||
doc.numbering = Arc::new(list::parse(&table, &fib, &fonts));
|
||||
let sheet: StyleSheet = StyleSheet::clone(&doc.styles);
|
||||
let sects = sections::parse(&word, &table, &fib);
|
||||
let stories = sections::header_stories(&table, &fib);
|
||||
// Subdocument layout in the CP space: main document, footnotes, headers, comments,
|
||||
@@ -722,85 +724,6 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
|
||||
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
|
||||
/// (`#bookmark` for internal links); `None` when not a hyperlink.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
let t = instr.trim();
|
||||
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
|
||||
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url: Option<String> = None;
|
||||
let mut local: Option<String> = None;
|
||||
let mut switch: Option<&str> = None;
|
||||
|
||||
let mut i = 0;
|
||||
let bytes = rest.as_bytes();
|
||||
while i < bytes.len() {
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[i] == b'\\' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
|
||||
i += 1;
|
||||
}
|
||||
switch = Some(rest.get(start..i).unwrap_or(""));
|
||||
continue;
|
||||
}
|
||||
let arg = if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'"' {
|
||||
i += 1;
|
||||
}
|
||||
let val = rest.get(start..i).unwrap_or("");
|
||||
if i < bytes.len() && bytes[i] == b'"' {
|
||||
i += 1;
|
||||
}
|
||||
val.to_string()
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
rest.get(start..i).unwrap_or("").to_string()
|
||||
};
|
||||
let clean_arg = arg.replace("%22", "\"");
|
||||
match switch.take() {
|
||||
Some("\\l") => local = Some(clean_arg),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if url.is_none() {
|
||||
url = Some(clean_arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (url, local) {
|
||||
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
|
||||
}
|
||||
(Some(u), _) if !u.is_empty() => Some(u),
|
||||
(None, Some(l)) if !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
Some(format!("#{frag}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
|
||||
fn pic_location(chpx: &[u8]) -> Option<u32> {
|
||||
for prl in sprm::iter(chpx) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::para::Paragraph;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
|
||||
use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge};
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, para_block};
|
||||
|
||||
@@ -458,32 +458,12 @@ fn def_table_edges(operand: &[u8]) -> Vec<f32> {
|
||||
}
|
||||
|
||||
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
|
||||
/// left, bottom, right, inside-horizontal, inside-vertical.
|
||||
/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are.
|
||||
fn table_borders(operand: &[u8]) -> Option<Borders> {
|
||||
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80);
|
||||
let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80);
|
||||
Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) })
|
||||
}
|
||||
|
||||
/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte.
|
||||
fn brc80(b: &[u8]) -> Option<Border> {
|
||||
let [w, ty, ico, flags] = b else { return None };
|
||||
let style = match ty {
|
||||
0x00 => BorderStyle::None,
|
||||
0x02 | 0x15 => BorderStyle::Thick,
|
||||
0x03 => BorderStyle::Double,
|
||||
0x04 | 0x05 => BorderStyle::Dotted,
|
||||
0x06 | 0x07 => BorderStyle::Dashed,
|
||||
0x08 | 0x09 => BorderStyle::DotDash,
|
||||
0x0A | 0x18 => BorderStyle::Wave,
|
||||
0x11 => BorderStyle::Triple,
|
||||
_ => BorderStyle::Single,
|
||||
};
|
||||
if *ty == 0 && *w == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 })
|
||||
}
|
||||
|
||||
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
|
||||
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
|
||||
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
|
||||
|
||||
@@ -1209,6 +1209,53 @@ fn table_merges_borders_shading() {
|
||||
assert_eq!(top.width, 0.5);
|
||||
}
|
||||
|
||||
/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border.
|
||||
#[test]
|
||||
fn table_borders_read_border_types_as_paragraphs_do() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let text = "a\u{7}\u{7}tail\r";
|
||||
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
|
||||
let row_end = {
|
||||
let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat();
|
||||
v.extend_from_slice(&0xD605u16.to_le_bytes());
|
||||
v.push(0x18);
|
||||
// Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero.
|
||||
for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] {
|
||||
v.extend_from_slice(&brc);
|
||||
}
|
||||
v
|
||||
};
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
header_stories: vec![],
|
||||
sections: vec![(text.chars().count() as u32, Vec::new())],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))],
|
||||
plf_lst: Vec::new(),
|
||||
plf_lfo: Vec::new(),
|
||||
note_stories: vec![],
|
||||
endnote_stories: vec![],
|
||||
extra_pairs: Vec::new(),
|
||||
data_stream: Vec::new(),
|
||||
};
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
|
||||
let b = tbl.props.borders.expect("table borders");
|
||||
let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width)));
|
||||
let want = [
|
||||
(S::Dotted, true, 1.0),
|
||||
(S::Triple, true, 1.0),
|
||||
(S::Wave, true, 1.0),
|
||||
(S::None, false, 0.0),
|
||||
(S::Single, true, 12.0),
|
||||
(S::None, false, 0.25),
|
||||
];
|
||||
assert_eq!(seen, want.map(Some));
|
||||
// A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are.
|
||||
let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph");
|
||||
assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// D3: lists (PlfLst + PlfLfo) and the combined document.
|
||||
|
||||
@@ -1469,6 +1516,16 @@ fn hyperlink_target_needs_the_whole_field_name() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
|
||||
}
|
||||
|
||||
/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX.
|
||||
#[test]
|
||||
fn hyperlink_target_keeps_the_address_after_a_bare_switch() {
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into()));
|
||||
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_become_objects() {
|
||||
let text = "Hello world\r";
|
||||
@@ -2482,17 +2539,21 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
|
||||
}
|
||||
|
||||
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
|
||||
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick
|
||||
/// border (type 0x02) stays thick in both, as tables read it too.
|
||||
#[test]
|
||||
fn paragraph_borders_read_from_both_forms() {
|
||||
use wordcraft_doc::props::BorderStyle as S;
|
||||
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
|
||||
let thick80 = [8u8, 0x02, 0x06, 0x01];
|
||||
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
|
||||
let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00];
|
||||
let text = "boxed\r\r";
|
||||
let spec = StructSpec {
|
||||
text,
|
||||
papx_entries: vec![
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc)])),
|
||||
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
|
||||
(6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])),
|
||||
],
|
||||
..structured_spec(text, text.chars().count() as u32)
|
||||
};
|
||||
@@ -2500,13 +2561,16 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
|
||||
let b = p0.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick");
|
||||
assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some());
|
||||
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
|
||||
let top = p1.props.borders.expect("borders").top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
let b = p1.props.borders.expect("borders");
|
||||
let top = b.top.expect("top border");
|
||||
assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick");
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
|
||||
@@ -172,8 +172,9 @@ impl PropCtx {
|
||||
}
|
||||
|
||||
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
|
||||
/// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as
|
||||
/// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
/// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL
|
||||
/// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b,
|
||||
/// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one.
|
||||
fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option<Rgb> {
|
||||
let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i);
|
||||
let slot = match e.attr(attr)? {
|
||||
@@ -199,8 +200,7 @@ impl PropCtx {
|
||||
let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0);
|
||||
if let Some(t) = byte(tint) {
|
||||
c = adjust_lum(c, |l| l * t + (1.0 - t));
|
||||
}
|
||||
if let Some(s) = byte(shade) {
|
||||
} else if let Some(s) = byte(shade) {
|
||||
c = adjust_lum(c, |l| l * s);
|
||||
}
|
||||
Some(c)
|
||||
@@ -208,6 +208,10 @@ impl PropCtx {
|
||||
|
||||
/// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour.
|
||||
pub fn shd_fill(&self, e: &El) -> Option<Rgb> {
|
||||
// `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78).
|
||||
if e.attr("w:val") == Some("nil") {
|
||||
return None;
|
||||
}
|
||||
if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") {
|
||||
return Some(c);
|
||||
}
|
||||
@@ -282,7 +286,7 @@ impl PropCtx {
|
||||
c.underline_color =
|
||||
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
|
||||
}
|
||||
"w:bdr" => c.border = Some(border(k)),
|
||||
"w:bdr" => c.border = Some(self.border(k)),
|
||||
"w:shd" => c.shading = self.shd_fill(k),
|
||||
"w:vertAlign" => {
|
||||
c.vert_align = k.attr("w:val").map(|v| match v {
|
||||
@@ -361,7 +365,7 @@ impl PropCtx {
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
"w:pBdr" => p.borders = Some(borders(k)),
|
||||
"w:pBdr" => p.borders = Some(self.borders(k)),
|
||||
"w:shd" => p.shading = self.shd_fill(k),
|
||||
"w:tabs" => {
|
||||
let tabs: Vec<TabStop> = k.children("w:tab").take(64).filter_map(tab_stop).collect();
|
||||
@@ -476,29 +480,32 @@ fn shd_fill(e: &El) -> Option<Rgb> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn border(e: &El) -> Border {
|
||||
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
|
||||
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
|
||||
let color = e.attr("w:color").and_then(Rgb::parse);
|
||||
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
|
||||
Border { style, width, color, space }
|
||||
}
|
||||
impl PropCtx {
|
||||
/// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text.
|
||||
pub fn border(&self, e: &El) -> Border {
|
||||
let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single"));
|
||||
let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0);
|
||||
let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse));
|
||||
let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0);
|
||||
Border { style, width, color, space }
|
||||
}
|
||||
|
||||
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
|
||||
pub fn borders(e: &El) -> Borders {
|
||||
let mut b = Borders::default();
|
||||
for k in e.els() {
|
||||
match k.local() {
|
||||
"top" => b.top = Some(border(k)),
|
||||
"left" | "start" => b.left = Some(border(k)),
|
||||
"bottom" => b.bottom = Some(border(k)),
|
||||
"right" | "end" => b.right = Some(border(k)),
|
||||
"between" | "insideH" => b.between = Some(border(k)),
|
||||
"insideV" => b.inside_v = Some(border(k)),
|
||||
_ => {}
|
||||
/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`.
|
||||
pub fn borders(&self, e: &El) -> Borders {
|
||||
let mut b = Borders::default();
|
||||
for k in e.els() {
|
||||
match k.local() {
|
||||
"top" => b.top = Some(self.border(k)),
|
||||
"left" | "start" => b.left = Some(self.border(k)),
|
||||
"bottom" => b.bottom = Some(self.border(k)),
|
||||
"right" | "end" => b.right = Some(self.border(k)),
|
||||
"between" | "insideH" => b.between = Some(self.border(k)),
|
||||
"insideV" => b.inside_v = Some(self.border(k)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
|
||||
@@ -532,7 +539,7 @@ impl PropCtx {
|
||||
}
|
||||
"w:jc" => t.align = k.attr("w:val").map(align),
|
||||
"w:tblInd" => t.indent = tw(k, "w:w"),
|
||||
"w:tblBorders" => t.borders = Some(borders(k)),
|
||||
"w:tblBorders" => t.borders = Some(self.borders(k)),
|
||||
"w:shd" => t.shading = self.shd_fill(k),
|
||||
"w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"),
|
||||
"w:bidiVisual" => t.bidi_visual = on_off(k),
|
||||
@@ -638,7 +645,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
|
||||
"w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63),
|
||||
"w:hMerge" => hcont = k.attr("w:val") != Some("restart"),
|
||||
"w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue },
|
||||
"w:tcBorders" => c.borders = Some(borders(k)),
|
||||
"w:tcBorders" => c.borders = Some(pc.borders(k)),
|
||||
"w:shd" => c.shading = pc.shd_fill(k),
|
||||
"w:noWrap" => c.no_wrap = on_off(k),
|
||||
"w:hideMark" => c.hide_mark = on_off(k),
|
||||
@@ -675,7 +682,7 @@ pub struct HfRef {
|
||||
}
|
||||
|
||||
/// `w:sectPr` (header/footer references are returned for the caller to resolve).
|
||||
pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
let mut s = SectionProps::default();
|
||||
let mut refs = Vec::new();
|
||||
for k in e.els() {
|
||||
@@ -780,7 +787,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
|
||||
_ => PageVAlign::Top,
|
||||
}
|
||||
}
|
||||
"w:pgBorders" => s.page_borders = Some(borders(k)),
|
||||
"w:pgBorders" => s.page_borders = Some(pc.borders(k)),
|
||||
"w:docGrid" => {
|
||||
let mut g = DocGrid::default();
|
||||
if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) {
|
||||
|
||||
@@ -289,10 +289,10 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps {
|
||||
let (mut sp, refs) = sectpr(s);
|
||||
let (mut sp, refs) = sectpr(&self.pc, s);
|
||||
// §17.13.5.32: the section's properties before a tracked change (no header references).
|
||||
if let Some(ch) = s.child("w:sectPrChange") {
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default();
|
||||
let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default();
|
||||
sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
for r in refs {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx};
|
||||
use wordcraft_doc::link::hyperlink_target;
|
||||
|
||||
/// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word
|
||||
/// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text
|
||||
@@ -24,69 +25,6 @@ fn is_range_instr(instr: &str) -> bool {
|
||||
crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
|
||||
}
|
||||
|
||||
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
|
||||
/// `#bookmark`. None for any other field, or one with no target.
|
||||
fn hyperlink_target(instr: &str) -> Option<String> {
|
||||
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
|
||||
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let args = field_args(instr);
|
||||
let mut args = args.iter();
|
||||
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
|
||||
return None;
|
||||
}
|
||||
let (mut url, mut anchor) = (None, None);
|
||||
while let Some((a, quoted)) = args.next() {
|
||||
if *quoted || !a.starts_with('\\') {
|
||||
url = url.or(Some(a.as_str()));
|
||||
continue;
|
||||
}
|
||||
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
|
||||
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
|
||||
if a.eq_ignore_ascii_case("\\l") {
|
||||
anchor = value.map(|(v, _)| v.as_str());
|
||||
}
|
||||
}
|
||||
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
|
||||
match (url, anchor) {
|
||||
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
|
||||
(Some(u), None) => Some(u.to_string()),
|
||||
(None, Some(a)) => Some(format!("#{a}")),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field code's words: the field's name, its switches and its arguments, each with whether
|
||||
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
|
||||
fn field_args(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
let mut word = String::new();
|
||||
if c == '"' {
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
|
||||
_ => word.push(c),
|
||||
}
|
||||
}
|
||||
out.push((word, true));
|
||||
} else {
|
||||
word.push(c);
|
||||
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
|
||||
word.push(c);
|
||||
}
|
||||
out.push((word, false));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
// ---- fields ----
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
|
||||
@@ -186,7 +187,7 @@ impl Reader<'_> {
|
||||
if !sheet.styles.iter().any(|s| s.quick) {
|
||||
sheet.add_default_gallery();
|
||||
}
|
||||
self.doc.styles = sheet;
|
||||
self.doc.styles = Arc::new(sheet);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ impl Reader<'_> {
|
||||
let mut t = TableStyleParts::default();
|
||||
if let Some(tp) = s.child("w:tblPr") {
|
||||
if let Some(b) = tp.child("w:tblBorders") {
|
||||
t.borders = Some(props::borders(b));
|
||||
t.borders = Some(self.pc.borders(b));
|
||||
}
|
||||
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
|
||||
t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
|
||||
@@ -210,7 +211,7 @@ impl Reader<'_> {
|
||||
let f = PartFormat {
|
||||
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
|
||||
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
|
||||
borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)),
|
||||
align: ppr.as_ref().and_then(|p| p.align),
|
||||
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
|
||||
"center" => VAlign::Center,
|
||||
@@ -229,7 +230,7 @@ impl Reader<'_> {
|
||||
t.fill = f.fill;
|
||||
}
|
||||
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
|
||||
t.borders = Some(props::borders(b));
|
||||
t.borders = Some(self.pc.borders(b));
|
||||
}
|
||||
if f.v_align.is_some() {
|
||||
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
|
||||
@@ -298,8 +299,8 @@ impl Reader<'_> {
|
||||
a.levels = levels;
|
||||
}
|
||||
}
|
||||
self.doc.numbering.abstracts = abstracts;
|
||||
self.doc.numbering.nums = nums;
|
||||
Arc::make_mut(&mut self.doc.numbering).abstracts = abstracts;
|
||||
Arc::make_mut(&mut self.doc.numbering).nums = nums;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! in a right-to-left paragraph.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Paragraph, para_block};
|
||||
@@ -189,6 +190,6 @@ fn a_language_the_run_already_has_is_kept() {
|
||||
#[test]
|
||||
fn an_inherited_bidi_language_is_not_repeated() {
|
||||
let mut d = doc_of("سلام WordCraft دنیا", true, arial());
|
||||
d.styles.default_chr.lang_bidi = Some("fa-IR".into());
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang_bidi = Some("fa-IR".into());
|
||||
assert_eq!(langs(&d), vec![(true, None), (false, None), (true, None)]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! reopen. An unset default is resolved as `DEFAULT_FONT_SIZE`; the writer must say so, because
|
||||
//! the reader reads an omitted size as 10 pt.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::{CharProps, DEFAULT_FONT_SIZE};
|
||||
fn resolved(d: &Document) -> f32 {
|
||||
@@ -19,7 +20,7 @@ fn defaults(xml: &str) -> &str {
|
||||
#[test]
|
||||
fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
assert_eq!(resolved(&d), DEFAULT_FONT_SIZE);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(defaults(&styles_xml(&bytes)).contains(r#"<w:sz w:val="22"/>"#));
|
||||
@@ -33,7 +34,7 @@ fn an_unset_default_size_keeps_its_effective_size_across_save_and_reopen() {
|
||||
fn an_explicit_default_size_is_written_as_it_is() {
|
||||
for size in [8.0, 10.0, 11.0, 12.5, 18.0] {
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = Some(size);
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = Some(size);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert_eq!(resolved(&wordcraft_docx::read(&bytes).unwrap()), size);
|
||||
}
|
||||
@@ -59,8 +60,8 @@ fn an_unset_default_size_does_not_take_a_styles_size() {
|
||||
// The written default is the resolver's own fallback, not what the Normal style resolves to,
|
||||
// so a style that sets a size keeps it and one that does not still falls back to the default.
|
||||
let mut d = Document::new();
|
||||
d.styles.default_chr.size = None;
|
||||
if let Some(normal) = d.styles.get_mut("Normal") {
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = None;
|
||||
if let Some(normal) = Arc::make_mut(&mut d.styles).get_mut("Normal") {
|
||||
normal.chr.size = Some(14.0);
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
@@ -252,7 +253,7 @@ fn omitted_paragraph_defaults_can_be_edited() {
|
||||
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
|
||||
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
|
||||
assert!(!d.styles.default_para_present);
|
||||
d.styles.default_para.space_after = Some(12.0);
|
||||
Arc::make_mut(&mut d.styles).default_para.space_after = Some(12.0);
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert!(again.styles.default_para_present);
|
||||
assert_eq!(again.styles.default_para.space_after, Some(12.0));
|
||||
@@ -2169,6 +2170,68 @@ fn theme_colour_wins_over_the_fixed_colour() {
|
||||
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
|
||||
}
|
||||
|
||||
/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name.
|
||||
#[test]
|
||||
fn nil_shading_paints_no_fill() {
|
||||
let body = r#"
|
||||
<w:tbl><w:tblGrid><w:gridCol w:w="200"/><w:gridCol w:w="2000"/></w:tblGrid><w:tr>
|
||||
<w:tc><w:tcPr><w:shd w:val="nil" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc>
|
||||
<w:tc><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C00000"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p><w:pPr><w:shd w:val="nil" w:fill="C00000" w:themeFill="accent2"/></w:pPr><w:r><w:rPr><w:shd w:val="nil" w:color="C00000" w:fill="C00000"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let Block::Table(t) = &*d.body[0] else { panic!("table first") };
|
||||
assert_eq!(t.rows[0].cells[0].props.shading, None);
|
||||
assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0)));
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].props.shading, None);
|
||||
assert_eq!(p[0].props_of_char(0).shading, None);
|
||||
}
|
||||
|
||||
/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black.
|
||||
#[test]
|
||||
fn border_theme_colour_wins_over_the_fixed_colour() {
|
||||
let body = r#"
|
||||
<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="background1"/><w:left w:val="single" w:sz="4" w:color="FF0000"/></w:pBdr></w:pPr><w:r><w:rPr><w:bdr w:val="single" w:sz="4" w:color="auto" w:themeColor="accent2"/></w:rPr><w:t>x</w:t></w:r></w:p>
|
||||
<w:tbl><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="accent1"/></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders><w:left w:val="single" w:sz="4" w:color="000000" w:themeColor="accent6" w:themeShade="BF"/></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
|
||||
<w:p/>
|
||||
<w:sectPr><w:pgBorders><w:top w:val="single" w:sz="4" w:color="000000" w:themeColor="text2"/></w:pgBorders></w:sectPr>"#;
|
||||
let styles = r#"<w:style w:type="table" w:styleId="T"><w:name w:val="T"/><w:tblPr><w:tblBorders><w:bottom w:val="single" w:sz="4" w:color="000000" w:themeColor="accent3"/></w:tblBorders></w:tblPr></w:style>"#;
|
||||
let d = themed_docx(body, styles, None);
|
||||
let p = paras(&d);
|
||||
let pb = p[0].props.borders.unwrap();
|
||||
assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF)));
|
||||
assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one");
|
||||
assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31)));
|
||||
let Block::Table(t) = &*d.body[1] else { panic!("table second") };
|
||||
assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4)));
|
||||
let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color);
|
||||
assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}");
|
||||
assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A)));
|
||||
let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders);
|
||||
assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5)));
|
||||
}
|
||||
|
||||
/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours,
|
||||
/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a).
|
||||
#[test]
|
||||
fn theme_tint_wins_over_theme_shade() {
|
||||
let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#;
|
||||
let body = format!(
|
||||
r#"<w:p><w:pPr><w:pBdr><w:top w:val="single" w:sz="4" w:color="000000" {both}/></w:pBdr></w:pPr><w:r><w:rPr><w:color w:val="000000" {both}/><w:bdr w:val="single" w:sz="4" w:color="000000" {both}/><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent1" w:themeFillTint="99" w:themeFillShade="7F"/></w:rPr><w:t>a</w:t></w:r></w:p>"#
|
||||
);
|
||||
let d = themed_docx(&body, "", None);
|
||||
let p = paras(&d);
|
||||
let ch = p[0].props_of_char(0);
|
||||
// accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val.
|
||||
let tinted = Rgb(0x8E, 0xAA, 0xDB);
|
||||
assert!(near(ch.color, tinted), "text colour {:?}", ch.color);
|
||||
let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(run_border, tinted), "text border {run_border:?}");
|
||||
let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb);
|
||||
assert!(near(para_border, tinted), "paragraph border {para_border:?}");
|
||||
assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_colours_follow_the_colour_scheme_mapping() {
|
||||
// Styles are read before settings; the mapping still applies to them.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block};
|
||||
|
||||
@@ -191,7 +192,7 @@ fn glossary_entries_are_read_into_the_model() {
|
||||
fn edited_glossary_is_written_from_the_model() {
|
||||
let mut doc = wordcraft_docx::read(&with_glossary()).unwrap();
|
||||
doc.glossary[0].name = "Sign-off".into();
|
||||
let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let mut listed = Paragraph::with_text("Second line", CharProps::default());
|
||||
listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
doc.glossary.push(BuildingBlock {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
@@ -272,7 +273,7 @@ fn comment_references_use_the_documents_annotation_reference_style() {
|
||||
assert!(!body.contains(r#"w:val="CommentReference""#), "{body}");
|
||||
// A document without that style keeps the English id.
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference"));
|
||||
let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap();
|
||||
doc.body = rebuilt.body.clone();
|
||||
doc.parts = rebuilt.parts.clone();
|
||||
@@ -314,7 +315,7 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
|
||||
// A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid.
|
||||
let mut copy = doc.numbering.abstracts[0].clone();
|
||||
copy.id = 7;
|
||||
doc.numbering.abstracts.push(copy);
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(copy);
|
||||
let n = text(&save(&doc), "word/numbering.xml");
|
||||
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ fn every_para_prop_round_trips() {
|
||||
#[test]
|
||||
fn styles_and_lists_round_trip() {
|
||||
let mut d = Document::new();
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "MyStyle".into(),
|
||||
name: "My Style".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -265,7 +265,7 @@ fn styles_and_lists_round_trip() {
|
||||
default: false,
|
||||
table: None,
|
||||
});
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "MyStyleChar".into(),
|
||||
name: "My Style Char".into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -274,10 +274,10 @@ fn styles_and_lists_round_trip() {
|
||||
hidden: true,
|
||||
..Default::default()
|
||||
});
|
||||
let bullets = d.numbering.add_list(ListKind::Bullet);
|
||||
let nums = d.numbering.add_list(ListKind::Legal);
|
||||
let outline = d.numbering.add_list(ListKind::Outline);
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == outline) {
|
||||
let bullets = Arc::make_mut(&mut d.numbering).add_list(ListKind::Bullet);
|
||||
let nums = Arc::make_mut(&mut d.numbering).add_list(ListKind::Legal);
|
||||
let outline = Arc::make_mut(&mut d.numbering).add_list(ListKind::Outline);
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == outline) {
|
||||
n.start_overrides.push((0, 4));
|
||||
}
|
||||
let mut ps = Vec::new();
|
||||
@@ -567,7 +567,7 @@ fn table_style_formatting_round_trips() {
|
||||
band_size: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
d.styles.upsert(Style {
|
||||
Arc::make_mut(&mut d.styles).upsert(Style {
|
||||
id: "Whole".into(),
|
||||
name: "Whole".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -620,7 +620,7 @@ fn every_table_style_part_round_trips() {
|
||||
};
|
||||
st.set_table_part(part, f);
|
||||
}
|
||||
d.styles.upsert(st.clone());
|
||||
Arc::make_mut(&mut d.styles).upsert(st.clone());
|
||||
let r = rt(&d);
|
||||
let got = r.styles.get("Parts").unwrap();
|
||||
for part in TablePart::ALL {
|
||||
@@ -1708,7 +1708,7 @@ fn kitchen_sink() -> Document {
|
||||
let mut d = Document::new();
|
||||
let png = tiny_png();
|
||||
let key = d.add_media(png, "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let f = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Note", CharProps::default()))]);
|
||||
let c = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Comment", CharProps::default()))]);
|
||||
@@ -1940,10 +1940,10 @@ fn list_level_overrides_round_trip() {
|
||||
use wordcraft_doc::numbering::{Counters, Level};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
let mut d = Document::new();
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let restart = d.numbering.restart(num).unwrap();
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let restart = Arc::make_mut(&mut d.numbering).restart(num).unwrap();
|
||||
let own = Level { format: NumFormat::DecimalZero, text: "%1.%2".into(), indent: 26.5, hanging: 26.5, ..Level::default() };
|
||||
if let Some(n) = d.numbering.nums.iter_mut().find(|n| n.id == restart) {
|
||||
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == restart) {
|
||||
n.level_overrides = vec![(1, own.clone())];
|
||||
}
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
|
||||
@@ -32,7 +32,7 @@ fn sample() -> Document {
|
||||
d.core.title = "Garden <notes> & more".into();
|
||||
d.core.creator = "Ada".into();
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let num = d.numbering.add_list(ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
|
||||
let head = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let foot = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Sown in May.", CharProps::default()))]);
|
||||
let com = d.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("Too short?", CharProps::default()))]);
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
Block, CharProps, CmdError, CmdResult, Document, FieldCode, InlineObject, Paragraph, Path, Pos, Session, SortKey, StoryRef, TabAlign, TabLeader,
|
||||
TabStop, Value, delete_selection, heading_letter, p, sel_result, sort_key,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
|
||||
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
|
||||
@@ -222,7 +223,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// layout cache starts over (and the saved file would gain a style it doesn't use).
|
||||
fn add_entry_style(s: &mut Session) {
|
||||
if s.doc.styles.get(ENTRY_STYLE).is_none() {
|
||||
s.doc.styles.upsert(wordcraft_doc::Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(wordcraft_doc::Style {
|
||||
id: ENTRY_STYLE.into(),
|
||||
name: "List Entry".into(),
|
||||
kind: wordcraft_doc::StyleKind::Paragraph,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! typing and deleting commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::control::{ContentControl, ControlKind, ControlLock, ControlRange, ListItem, MAX_CONTROL_TEXT, MAX_LIST_ITEMS};
|
||||
use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::props::{CharProps, Rgb, TextColor};
|
||||
@@ -172,7 +173,7 @@ fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String {
|
||||
/// The placeholder style, added to the document when it hasn't one.
|
||||
fn ensure_placeholder_style(s: &mut Session) {
|
||||
if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() {
|
||||
s.doc.styles.styles.push(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(Style {
|
||||
id: PLACEHOLDER_STYLE.into(),
|
||||
name: PLACEHOLDER_STYLE_NAME.into(),
|
||||
kind: StyleKind::Character,
|
||||
@@ -799,6 +800,8 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod editing_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::control::ControlKind;
|
||||
@@ -829,8 +832,8 @@ mod editing_tests {
|
||||
let typed_style = |run_props: Option<CharProps>| {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Platzhaltertext", "Placeholder Text", grey));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() }));
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "name"}));
|
||||
let start = s.doc.control_at(&s.sel.focus).unwrap().start;
|
||||
@@ -852,7 +855,7 @@ mod editing_tests {
|
||||
// style goes all the same.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() };
|
||||
s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey));
|
||||
run(&mut s, "text.insert", json!({"text": "Nom : "}));
|
||||
run(&mut s, "developer.plainText", json!({"tag": "nom"}));
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
@@ -95,8 +96,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
s.doc.settings.major_font = heading.into();
|
||||
s.doc.settings.minor_font = body.into();
|
||||
s.doc.styles.default_chr.font = Some(body.into());
|
||||
for st in &mut s.doc.styles.styles {
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.font = Some(body.into());
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
if let Some(f) = &st.chr.font
|
||||
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
|
||||
{
|
||||
@@ -108,7 +109,7 @@ fn set_fonts(s: &mut Session, heading: &str, body: &str) {
|
||||
}
|
||||
|
||||
fn set_accent(s: &mut Session, accent: Rgb) {
|
||||
for st in &mut s.doc.styles.styles {
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5')
|
||||
|| st.id == "IntenseQuote"
|
||||
|| st.id == "IntenseEmphasis"
|
||||
@@ -173,15 +174,16 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
let sheet = Arc::make_mut(&mut s.doc.styles);
|
||||
for mut st in fresh.styles {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
match sheet.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => s.doc.styles.styles.push(st),
|
||||
None => sheet.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
@@ -200,9 +202,9 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"double" => (0.0, 8.0, 2.0),
|
||||
_ => (0.0, 8.0, 1.15),
|
||||
};
|
||||
s.doc.styles.default_para.space_before = Some(before);
|
||||
s.doc.styles.default_para.space_after = Some(after);
|
||||
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_before = Some(before);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.space_after = Some(after);
|
||||
Arc::make_mut(&mut s.doc.styles).default_para.line_spacing = Some(LineSpacing::Multiple(line));
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -227,7 +229,7 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = &mut s.doc.styles;
|
||||
let st = Arc::make_mut(&mut s.doc.styles);
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{Block, Document, StoryRef};
|
||||
|
||||
use super::{pos_json, sel_result};
|
||||
@@ -127,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
|
||||
.params(
|
||||
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
r#"{"path": string, "pagesPerSheet"?: 1 | 2 | 4 | 6 | 9 | 16 | "booklet" | "bookletReverse", "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("file.print", "Print", "File", |s, _| {
|
||||
@@ -230,7 +231,7 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
Arc::make_mut(&mut d.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -271,7 +272,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
|
||||
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
|
||||
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
|
||||
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
|
||||
use wordcraft_doc::section::SectionProps;
|
||||
use wordcraft_doc::table::{Cell, Row, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -212,6 +212,25 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
|
||||
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
|
||||
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
|
||||
/// another author's is superseded.
|
||||
pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> {
|
||||
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) };
|
||||
let revisions = s.doc.revisions.clone();
|
||||
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None };
|
||||
if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) {
|
||||
let p = s.doc.para_mut(story, path)?;
|
||||
p.props.fmt_change = want;
|
||||
p.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted
|
||||
/// differently from `old` as tracked formatting changes by `author`.
|
||||
pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec<Revision>, author: &str, date: &str) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`; and
|
||||
//! the fonts the document's text asks for that aren't installed, with the fonts drawn instead
|
||||
//! (`font.missing`).
|
||||
//!
|
||||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||||
@@ -6,7 +8,9 @@
|
||||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_fonts::{FontDb, UserFont};
|
||||
use wordcraft_layout::DocLayout;
|
||||
|
||||
use crate::cmd::insert::base64_decode;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -19,6 +23,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
CommandSpec::new("font.loadSystem", "Load System Installed Fonts…", "Home › Font", load_system).pure(),
|
||||
CommandSpec::new("font.missing", "Missing Fonts", "Status Bar", missing)
|
||||
.params(r#"{} → {"missing": [{"font": string, "shownAs": [string] (the families drawn instead), "metricCompatible": bool (its substitute measures like the font)}]} — the text the current view shows"#)
|
||||
.pure(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,6 +124,102 @@ fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// A font the document's text asks for that isn't installed.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MissingFont {
|
||||
/// The name the document asks for.
|
||||
pub font: String,
|
||||
/// The families its text is drawn in instead: the substitute, and fallback faces for
|
||||
/// characters the substitute lacks.
|
||||
pub shown_as: Vec<String>,
|
||||
/// The font's substitute measures like it (Carlito for Calibri…): line breaks don't move.
|
||||
/// Fallback faces for characters the substitute lacks don't count: the font itself might
|
||||
/// lack them too (an emoji in Calibri text).
|
||||
pub metric_compatible: bool,
|
||||
}
|
||||
|
||||
/// The family text set in `font` is drawn in when `font` isn't installed (None when it is), as
|
||||
/// layout picks it for `doc`.
|
||||
pub fn shown_as(doc: &Document, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::substitute_face(doc, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// The family Chinese, Japanese and Korean text is drawn in when its East Asian font `font_ea`
|
||||
/// isn't installed (None when it is), the run's font being `font`, as layout picks it for `doc`.
|
||||
pub fn east_asian_shown_as(doc: &Document, font_ea: &str, font: &str) -> Option<String> {
|
||||
wordcraft_layout::para::east_asian_substitute_face(doc, font_ea, font).map(|f| f.get().family.clone())
|
||||
}
|
||||
|
||||
/// Whether text in `font` drawn in `substitute` is worth a warning: the substitute measures
|
||||
/// differently, so line breaks can move, and the font isn't a symbol font (drawn as its
|
||||
/// Unicode equivalents on purpose, which layout leaves out of [`missing_fonts`] too). Decides
|
||||
/// [`MissingFont::metric_compatible`], which the font box and the status bar both read.
|
||||
pub fn warns(font: &str, substitute: &str) -> bool {
|
||||
!wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::metric_compatible(font, substitute)
|
||||
}
|
||||
|
||||
/// The fonts `doc`'s laid-out text asks for that aren't installed, from what layout drew
|
||||
/// ([`DocLayout::substituted_fonts`]): only text the current view shows counts (no headers or
|
||||
/// footers in Web, Draft or Read view), in every story on its pages, list labels and field
|
||||
/// results included. Names differing only in case are one font.
|
||||
pub fn missing_fonts(doc: &Document, layout: &DocLayout) -> Vec<MissingFont> {
|
||||
// By key: in name order, case variants merged.
|
||||
let mut out: std::collections::BTreeMap<String, MissingFont> = std::collections::BTreeMap::new();
|
||||
for (recorded, face) in layout.substituted_fonts() {
|
||||
let family = face.get().family.clone();
|
||||
let font = shown_name(&recorded);
|
||||
let m = out.entry(font_key(font)).or_insert_with(|| MissingFont {
|
||||
font: font.to_string(),
|
||||
shown_as: Vec::new(),
|
||||
// Judged by the name layout looked up, spaces and all: "Arial " isn't Arial to it, so
|
||||
// its text is in a substitute even where Arial is installed.
|
||||
metric_compatible: shown_as(doc, &recorded).is_some_and(|s| !warns(font, &s)),
|
||||
});
|
||||
if !m.shown_as.contains(&family) {
|
||||
m.shown_as.push(family);
|
||||
}
|
||||
}
|
||||
out.into_values().collect()
|
||||
}
|
||||
|
||||
/// A font name as [`missing_fonts`] lists it: as layout records it (at most 256 characters),
|
||||
/// trimmed.
|
||||
fn shown_name(font: &str) -> &str {
|
||||
wordcraft_layout::para::reported_font_name(font).trim()
|
||||
}
|
||||
|
||||
/// The key a font name is listed under in [`missing_fonts`]: two names with the same key are
|
||||
/// one font. Look a document's font up in the list through this.
|
||||
pub fn font_key(font: &str) -> String {
|
||||
shown_name(font).to_lowercase()
|
||||
}
|
||||
|
||||
/// The status line's form of a font name: a hostile file's names are long.
|
||||
fn short_name(font: &str) -> String {
|
||||
match font.char_indices().nth(40) {
|
||||
Some((i, _)) => format!("{}…", font.get(..i).unwrap_or(font)),
|
||||
None => font.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn missing(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let layout = s.layout();
|
||||
let found = missing_fonts(&s.doc, &layout);
|
||||
// From command search there's no dialog: the status line says what was found.
|
||||
// A hostile file can name thousands of fonts: name the first few.
|
||||
const NAMED: usize = 5;
|
||||
let changing: Vec<&str> = found.iter().filter(|f| !f.metric_compatible).map(|f| f.font.as_str()).collect();
|
||||
let named = changing.iter().take(NAMED).map(|f| short_name(f)).collect::<Vec<_>>().join(", ");
|
||||
s.status = match (found.is_empty(), changing.len()) {
|
||||
(true, _) => "Every font the document shows is installed".into(),
|
||||
(false, 0) => "Fonts that aren't installed are shown in fonts that measure the same: line breaks don't move".into(),
|
||||
(_, n) if n > NAMED => format!("Not installed: {named} and {} more", n - NAMED),
|
||||
_ => format!("Not installed: {named}"),
|
||||
};
|
||||
let fonts: Vec<Value> = found.iter().map(|f| json!({"font": f.font, "shownAs": f.shown_as, "metricCompatible": f.metric_compatible})).collect();
|
||||
Ok(json!({"missing": fonts}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -866,8 +866,14 @@ pub fn state(s: &Session) -> Value {
|
||||
let at = selected_text_box(s).map_or_else(|| s.sel.focus.clone(), |(a, _)| a);
|
||||
let style = s.doc.para_at(&at).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
|
||||
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
|
||||
let font = same(&|r| json!(r.font));
|
||||
// The font's substitute when it isn't installed: the face its text is drawn in, apart from
|
||||
// characters the substitute lacks. `font.missing` lists every face the shown text is drawn
|
||||
// in, fallback faces too, and which fonts change the layout.
|
||||
let shown_as = font.as_str().and_then(|f| crate::cmd::font::shown_as(&s.doc, f));
|
||||
json!({
|
||||
"font": same(&|r| json!(r.font)),
|
||||
"font": font,
|
||||
"fontShownAs": shown_as,
|
||||
"fontEastAsia": same(&|r| json!(r.font_ea)),
|
||||
"size": same(&|r| json!(r.size)),
|
||||
"bold": all.iter().all(|r| r.bold),
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
|
||||
|
||||
use super::references::{FieldCode, leader};
|
||||
use super::references::{FieldCode, field_arg, leader};
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
@@ -44,11 +44,12 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result<String, CmdError> {
|
||||
let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") };
|
||||
if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) {
|
||||
instr.push_str(&format!(" \\t \"{see}\""));
|
||||
} else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) {
|
||||
} else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) {
|
||||
if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) {
|
||||
return Err(CmdError::Params(format!("no bookmark `{bm}`")));
|
||||
}
|
||||
instr.push_str(&format!(" \\r {}", bm.trim()));
|
||||
// A name read from a file may hold a space, a quote or a backslash.
|
||||
instr.push_str(&format!(" \\r {}", field_arg(bm)));
|
||||
}
|
||||
if p::bool(v, "bold").unwrap_or(false) {
|
||||
instr.push_str(" \\b");
|
||||
@@ -393,6 +394,36 @@ mod tests {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect()
|
||||
}
|
||||
|
||||
/// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file
|
||||
/// can hold) is quoted in the XE field, so it reads back whole.
|
||||
#[test]
|
||||
fn mark_entry_trims_and_quotes_its_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text."));
|
||||
s.run("select.text", &json!({"text": "Intro"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap();
|
||||
let odd = "My \"Mark\" \\b";
|
||||
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap();
|
||||
s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap();
|
||||
let mut instrs: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
instrs.sort();
|
||||
assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]);
|
||||
let beta = FieldCode::parse(&instrs[1]);
|
||||
assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false));
|
||||
}
|
||||
|
||||
/// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and
|
||||
/// the INDEX field's switches change the index: run-in subentries, letter headings, a tab
|
||||
/// before right-aligned page numbers, and two columns in a continuous section.
|
||||
|
||||
@@ -137,7 +137,8 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
|
||||
let (mut w, mut h) = (natural_w, natural_h);
|
||||
if let Some(want) = width {
|
||||
let k = want.max(4.0) / w.max(1.0);
|
||||
// At most 22 inches, as for icons: a huge width made the height of a tall picture infinite.
|
||||
let k = want.clamp(4.0, 1584.0) / w.max(1.0);
|
||||
w *= k;
|
||||
h *= k;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save
|
||||
/// writes as `null` and can't read back.
|
||||
#[test]
|
||||
fn a_huge_picture_width_keeps_the_size_finite() {
|
||||
let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap();
|
||||
let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap());
|
||||
assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}");
|
||||
let saved = crate::io::save_bytes("a.json", &s.doc).unwrap();
|
||||
assert!(crate::io::open_bytes("a.json", &saved).is_ok());
|
||||
}
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! the number and the number's font.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::StoryRef;
|
||||
use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for};
|
||||
use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor};
|
||||
@@ -183,26 +184,26 @@ fn define(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Some(n) => {
|
||||
// Change the list in place: every list sharing its definition follows.
|
||||
let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?;
|
||||
if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
if let Some(a) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == aid) {
|
||||
a.levels = levels.clone();
|
||||
if name.is_some() {
|
||||
a.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) {
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == n) {
|
||||
x.level_overrides.clear();
|
||||
}
|
||||
let sharing: Vec<u32> = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect();
|
||||
touch_lists(s, &sharing)?;
|
||||
n
|
||||
}
|
||||
None => s.doc.numbering.try_add_levels(name, levels.clone())?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_levels(name, levels.clone())?,
|
||||
};
|
||||
// Levels linked to a paragraph style number every paragraph in that style.
|
||||
let mut linked = Vec::new();
|
||||
for (i, l) in levels.iter().enumerate() {
|
||||
let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue };
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: i as u8 });
|
||||
linked.push(id);
|
||||
}
|
||||
@@ -292,11 +293,11 @@ fn preset_levels(preset: &str) -> Option<Vec<Level>> {
|
||||
fn preset(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = p::req_str(v, "preset")?;
|
||||
let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?;
|
||||
let num = s.doc.numbering.try_add_levels(None, levels)?;
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).try_add_levels(None, levels)?;
|
||||
let mut linked = Vec::new();
|
||||
for n in 1..=LEVELS as u8 {
|
||||
let id = super::para::ensure_heading_style(s, n);
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.para.numbering = Some(NumRef { num, level: n - 1 });
|
||||
linked.push(id);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use wordcraft_doc::props::{Align, CharProps};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::references::{field_arg, field_words, quoted};
|
||||
use super::{delete_selection, sel_result};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
@@ -122,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| {
|
||||
let f = p::req_str(v, "field")?.to_string();
|
||||
insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»"))
|
||||
insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»"))
|
||||
})
|
||||
.params(r#"{"field": string}"#)
|
||||
.key(super::non_mac("Alt+Shift+F")),
|
||||
@@ -136,14 +137,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase();
|
||||
let instr = match rule.as_str() {
|
||||
"IF" => format!(
|
||||
"IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"",
|
||||
quote(p::str(v, "field").unwrap_or("Field")),
|
||||
p::str(v, "value").unwrap_or(""),
|
||||
p::str(v, "then").unwrap_or(""),
|
||||
p::str(v, "else").unwrap_or("")
|
||||
"IF {{ MERGEFIELD {} }} = {} {} {}",
|
||||
field_arg(p::str(v, "field").unwrap_or("Field")),
|
||||
quoted(p::str(v, "value").unwrap_or("")),
|
||||
quoted(p::str(v, "then").unwrap_or("")),
|
||||
quoted(p::str(v, "else").unwrap_or(""))
|
||||
),
|
||||
"SKIPIF" | "NEXTIF" => {
|
||||
format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or(""))
|
||||
format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or("")))
|
||||
}
|
||||
"MERGEREC" => "MERGEREC".into(),
|
||||
_ => "NEXT".into(),
|
||||
@@ -227,10 +228,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn quote(f: &str) -> String {
|
||||
if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value,
|
||||
json, p, para_block,
|
||||
field_words, json, p, para_block,
|
||||
};
|
||||
|
||||
pub(super) fn step(s: &mut Session, d: i64) -> CmdResult {
|
||||
@@ -41,9 +41,7 @@ pub(super) fn mergefield_name(instr: &str) -> Option<String> {
|
||||
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.trim();
|
||||
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
|
||||
Some(name.to_string())
|
||||
Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Which columns hold address parts.
|
||||
@@ -103,17 +101,17 @@ fn rule_field(t: &str) -> Option<String> {
|
||||
|
||||
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
|
||||
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
|
||||
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec<String>) {
|
||||
let field = rule_field(t).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
let cmp = quoted.next().unwrap_or("");
|
||||
let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w);
|
||||
let cmp = texts.next().unwrap_or_default();
|
||||
let v = value(s, row, &field);
|
||||
// Numbers compare as numbers (Word: "7" = "7.0"), everything else as text.
|
||||
let ord = match (v.trim().parse::<f64>(), cmp.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => a.partial_cmp(&b),
|
||||
_ => Some(v.as_str().cmp(cmp)),
|
||||
_ => Some(v.as_str().cmp(cmp.as_str())),
|
||||
};
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
let holds = match op.as_str() {
|
||||
@@ -124,7 +122,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
">=" => matches!(ord, Some(Greater | Equal)),
|
||||
_ => ord == Some(Equal),
|
||||
};
|
||||
(holds, quoted.collect())
|
||||
(holds, texts.collect())
|
||||
}
|
||||
|
||||
/// The first word of a field instruction, upper-cased.
|
||||
@@ -149,7 +147,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
|
||||
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
|
||||
"IF" if rule_field(t).is_some() => {
|
||||
let (holds, texts) = condition(s, t, row);
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default())
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -307,6 +307,29 @@ fn if_rule_previews_its_result() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther");
|
||||
}
|
||||
|
||||
/// #3681: quotes and backslashes in a rule's texts and field name stay in them.
|
||||
#[test]
|
||||
fn rule_texts_keep_their_quotes() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]);
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"});
|
||||
s.run("mailings.rules", &rule).unwrap();
|
||||
s.run("mailings.preview", &json!({"value": true})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\"");
|
||||
s.run("mailings.next", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\");
|
||||
// SKIPIF compares with the value as typed.
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": rows})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap();
|
||||
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain");
|
||||
}
|
||||
|
||||
/// #430: merge fields in headers and footers are previewed and merged; each letter gets its
|
||||
/// own header.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
@@ -256,7 +257,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let on = p::bool(v, "value").unwrap_or(true);
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.quick = on;
|
||||
}
|
||||
sel_result(s)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::format::fmt;
|
||||
use super::{CmdError, CmdResult, LineSpacing, ListKind, NumRef, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
|
||||
pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
@@ -45,9 +46,9 @@ pub(super) fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
|
||||
None => match kind {
|
||||
ListKind::Bullet | ListKind::BulletChar(_) => match s.doc.numbering.find_kind(kind) {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
},
|
||||
_ => s.doc.numbering.try_add_list(kind)?,
|
||||
_ => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
},
|
||||
};
|
||||
fmt(s, &|p| {
|
||||
@@ -68,7 +69,7 @@ pub(super) fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
|
||||
return Err(CmdError::Failed("not in a numbered list".into()));
|
||||
};
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
// This paragraph and the following ones of the same list use the new instance.
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
|
||||
for path in paths {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::lists::touch_all;
|
||||
use super::{Block, CmdError, CmdResult, NumRef, ParaProps, Pos, Session, SortKey, Value, p, sel_result, sort_key};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Move Paragraph Up / Down (Alt+Shift+Up/Down, ⌃⇧↑/↓ on macOS): the paragraphs the selection
|
||||
/// touches swap places with the paragraph above or below, in their own container (the story or
|
||||
@@ -96,7 +97,7 @@ pub(crate) fn ensure_heading_style(s: &mut Session, n: u8) -> String {
|
||||
if s.doc.styles.get(&sid).is_none()
|
||||
&& let Some(st) = builtin.get(&sid).cloned()
|
||||
{
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
}
|
||||
}
|
||||
touch_all(s);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::format::fmt;
|
||||
use super::lists::touch_all;
|
||||
use super::{CharProps, CmdError, CmdResult, ParaProps, Session, Style, StyleKind, Value, json, p, sel_result};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
|
||||
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
|
||||
@@ -101,7 +102,7 @@ pub(super) fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
st.chr.fmt_change = None;
|
||||
st.para = st.para.formatting();
|
||||
}
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
apply_style(s, &json!({"style": id}))?;
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -113,7 +114,7 @@ pub(super) fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let next = p::str(v, "next").map(str::to_string);
|
||||
let name = p::str(v, "name").map(str::to_string);
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if let Some(c) = chr {
|
||||
st.chr.overlay(&c);
|
||||
}
|
||||
@@ -175,7 +176,7 @@ pub(super) fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?;
|
||||
let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() };
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
if let Some(st) = Arc::make_mut(&mut s.doc.styles).get_mut(&id) {
|
||||
st.chr = chr;
|
||||
st.para = pp;
|
||||
}
|
||||
@@ -263,7 +264,7 @@ pub(super) fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
let mut changed = 0;
|
||||
for g in &gone {
|
||||
s.doc.styles.remove(&g.id);
|
||||
Arc::make_mut(&mut s.doc.styles).remove(&g.id);
|
||||
// Fall back to the base only while it is a style of the same kind that still exists.
|
||||
let base = g.based_on.clone().filter(|b| s.doc.styles.get(b).is_some_and(|x| x.kind == g.kind));
|
||||
changed += s.doc.restyle(&g.id, base);
|
||||
@@ -279,7 +280,7 @@ pub(super) fn set_visibility(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if gallery.is_none() && hidden.is_none() {
|
||||
return Err(CmdError::Params("give `gallery` and/or `hidden`".into()));
|
||||
}
|
||||
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
let st = Arc::make_mut(&mut s.doc.styles).get_mut(&id).ok_or_else(|| CmdError::Params(format!("no style `{id}`")))?;
|
||||
if let Some(h) = hidden {
|
||||
st.hidden = h;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::{InlineObject, OBJ};
|
||||
use wordcraft_doc::props::{CellProps, CharProps, RowProps, TableProps};
|
||||
use wordcraft_doc::{Block, Blocks, Document, Path, PermRange, Pos, StoryRef};
|
||||
@@ -78,7 +79,7 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Disabled("stop protection before changing the formatting restrictions".into()));
|
||||
}
|
||||
let names: Vec<&str> = list.iter().filter_map(Value::as_str).take(10_000).collect();
|
||||
for st in &mut s.doc.styles.styles {
|
||||
for st in &mut Arc::make_mut(&mut s.doc.styles).styles {
|
||||
st.locked = names.iter().any(|n| *n == st.id || n.eq_ignore_ascii_case(&st.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
|
||||
#[test]
|
||||
fn formatting_restriction_refuses_attach_template_update() {
|
||||
let mut t = Document::from_text("Template.");
|
||||
if let Some(st) = t.styles.styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
if let Some(st) = Arc::make_mut(&mut t.styles).styles.iter_mut().find(|st| st.id == "Heading1") {
|
||||
st.chr.size = Some(31.0);
|
||||
}
|
||||
let data = crate::cmd::insert::base64_encode(&crate::io::save_bytes("t.docx", &t).unwrap());
|
||||
@@ -147,7 +147,7 @@ fn formatting_restriction_refuses_attach_template_update() {
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
// Copying styles in replaces their definitions, locked flag included (#3692).
|
||||
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
|
||||
@@ -16,7 +16,9 @@ mod properties;
|
||||
mod toc;
|
||||
|
||||
use fields::caption;
|
||||
pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq};
|
||||
pub use fields::{
|
||||
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq,
|
||||
};
|
||||
pub(super) use notes::note_options;
|
||||
use notes::{note, note_refs};
|
||||
pub use properties::{bookmark_text, date_picture};
|
||||
@@ -110,37 +112,61 @@ pub struct FieldCode {
|
||||
pub switches: Vec<(char, Option<String>)>,
|
||||
}
|
||||
|
||||
/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]).
|
||||
pub fn field_words(instr: &str) -> Vec<(String, bool)> {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
/// `s` as quoted field-code text, which [`field_words`] reads back as it was.
|
||||
pub fn quoted(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len() + 2);
|
||||
o.push('"');
|
||||
for c in s.chars() {
|
||||
if matches!(c, '"' | '\\') {
|
||||
o.push('\\');
|
||||
}
|
||||
o.push(c);
|
||||
}
|
||||
o.push('"');
|
||||
o
|
||||
}
|
||||
|
||||
/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`].
|
||||
pub fn field_arg(s: &str) -> String {
|
||||
if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() }
|
||||
}
|
||||
|
||||
impl FieldCode {
|
||||
pub fn parse(instr: &str) -> FieldCode {
|
||||
let mut words: Vec<(String, bool)> = Vec::new();
|
||||
let mut chars = instr.chars().peekable();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
chars.next();
|
||||
} else if c == '"' {
|
||||
chars.next();
|
||||
let mut w = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' => break,
|
||||
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
|
||||
c => w.push(c),
|
||||
}
|
||||
}
|
||||
words.push((w, true));
|
||||
} else {
|
||||
let mut w = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
|
||||
break;
|
||||
}
|
||||
w.push(c);
|
||||
chars.next();
|
||||
}
|
||||
words.push((w, false));
|
||||
}
|
||||
}
|
||||
let mut words = words.into_iter().peekable();
|
||||
let mut words = field_words(instr).into_iter().peekable();
|
||||
let mut f =
|
||||
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
|
||||
while let Some((w, quoted)) = words.next() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::properties::{
|
||||
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
};
|
||||
use super::toc::{heading_style_level, update_toc};
|
||||
use super::{
|
||||
@@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
|
||||
/// text or number of the nearest paragraph of a style.
|
||||
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
let mut headings = [0u32; 9];
|
||||
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
|
||||
// Built on the first STYLEREF (field results don't change what it finds).
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
|
||||
let numbers = seq_results(&s.doc);
|
||||
apply_results(&mut s.doc, numbers)?;
|
||||
let refs = styleref_results(&s.doc);
|
||||
apply_results(&mut s.doc, refs)
|
||||
}
|
||||
|
||||
/// A field's fresh result: its story and paragraph, its object index there and the text.
|
||||
type FieldResult = (StoryRef, Path, usize, String);
|
||||
|
||||
/// The SEQ numbering so far while the document is walked in order.
|
||||
#[derive(Default)]
|
||||
struct SeqCount {
|
||||
/// How many headings of each level there have been (for `\s`).
|
||||
headings: [u32; 9],
|
||||
/// Per sequence: its number, and the heading count it was last restarted at.
|
||||
seq: std::collections::HashMap<String, (u32, u32)>,
|
||||
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
|
||||
boxes: std::collections::HashSet<u32>,
|
||||
out: Vec<FieldResult>,
|
||||
}
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
if let Some(l) = heading_style_level(p)
|
||||
&& let Some(k) = (l as usize).checked_sub(1)
|
||||
&& let Some(count) = headings.get_mut(k)
|
||||
&& let Some(n) = count.headings.get_mut(k)
|
||||
{
|
||||
*count = count.saturating_add(1);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
let mut updates: Vec<(usize, String)> = Vec::new();
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
match f.name.as_str() {
|
||||
"SEQ" => {
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart =
|
||||
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
|
||||
let n = seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) }));
|
||||
}
|
||||
"STYLEREF" => {
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
count_seq(doc, StoryRef::Body, &path, p, &mut count);
|
||||
}
|
||||
count.out
|
||||
}
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
for bp in doc.para_paths(part) {
|
||||
if let Some(boxed) = doc.para(part, &bp) {
|
||||
count_seq(doc, part, &bp, boxed, count);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
continue;
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &path)?;
|
||||
for (k, r) in updates {
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
let restart = f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| count.headings.get(l)).copied();
|
||||
let n = count.seq.entry(id.clone()).or_insert((0, 0));
|
||||
if let Some(epoch) = restart
|
||||
&& n.1 != epoch
|
||||
{
|
||||
*n = (0, epoch);
|
||||
}
|
||||
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
|
||||
n.0 = r;
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
}
|
||||
}
|
||||
|
||||
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
|
||||
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
|
||||
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
// Built on the first STYLEREF.
|
||||
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
if FieldCode::parse(instr).name != "STYLEREF" {
|
||||
continue;
|
||||
}
|
||||
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
|
||||
if let Some(i) = ix.index_of(&path) {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What Word prints for a `REF` to a bookmark the document doesn't have.
|
||||
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
|
||||
|
||||
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
|
||||
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
|
||||
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
|
||||
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
|
||||
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
|
||||
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
|
||||
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
|
||||
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
|
||||
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
|
||||
/// fields refer to it.
|
||||
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut out = Vec::new();
|
||||
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
|
||||
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
|
||||
// The first of two bookmarks with one name, as `bookmark_text` takes.
|
||||
starts.entry(name).or_insert(start);
|
||||
}
|
||||
// A bookmark's text, `None` when it ends in a later paragraph.
|
||||
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
|
||||
// here: those keep their stored result rather than print the paragraph's text.
|
||||
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
|
||||
continue;
|
||||
}
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let Some(start) = starts.get(target) else {
|
||||
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
|
||||
continue;
|
||||
};
|
||||
let text = texts.entry(target.to_string()).or_insert_with(|| {
|
||||
let source = if start.story == StoryRef::Body { doc } else { stored };
|
||||
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
|
||||
});
|
||||
if let Some(r) = text.clone() {
|
||||
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
|
||||
/// order.
|
||||
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for id in doc.parts.keys() {
|
||||
let story = StoryRef::Part(*id);
|
||||
for path in doc.para_paths(story) {
|
||||
let Some(p) = doc.para(story, &path) else { continue };
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
|
||||
/// Word's F9 clears it too).
|
||||
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
|
||||
for (story, path, k, r) in results {
|
||||
let para = doc.para_mut(story, &path)?;
|
||||
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
|
||||
*result = r;
|
||||
*dirty = false;
|
||||
}
|
||||
para.touch();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `results` less those its fields already show.
|
||||
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
|
||||
results.retain(|(story, path, k, r)| {
|
||||
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
|
||||
});
|
||||
results
|
||||
}
|
||||
|
||||
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
|
||||
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
|
||||
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
|
||||
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
|
||||
/// fields whose stored results were wrong). The document itself keeps its stored results.
|
||||
/// `None` when every such field already shows its fresh result, so nothing is copied then.
|
||||
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
|
||||
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
|
||||
// and a REF to a bookmarked caption, show the new number.
|
||||
let seq = changed(doc, seq_results(doc));
|
||||
let mut out = (!seq.is_empty()).then(|| doc.clone());
|
||||
if let Some(d) = out.as_mut() {
|
||||
apply_results(d, seq).ok()?;
|
||||
}
|
||||
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
|
||||
if !styles.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
|
||||
}
|
||||
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
|
||||
if !refs.is_empty() {
|
||||
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
|
||||
fn has_switch(instr: &str, sw: char) -> bool {
|
||||
instr.split('\\').skip(1).any(|s| {
|
||||
|
||||
@@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option<String> {
|
||||
/// the paragraph's end. `None` when there is no such bookmark.
|
||||
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
|
||||
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
|
||||
bookmark_text_at(doc, name, &start)
|
||||
}
|
||||
|
||||
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
|
||||
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
|
||||
doc.para(start.story, &start.path).is_some_and(|para| {
|
||||
para.object_offsets()
|
||||
.into_iter()
|
||||
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
|
||||
/// looked the document's bookmarks up once.
|
||||
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
|
||||
let para = doc.para(start.story, &start.path)?;
|
||||
let end = para
|
||||
.object_offsets()
|
||||
.into_iter()
|
||||
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
|
||||
.unwrap_or(para.len());
|
||||
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
|
||||
}
|
||||
|
||||
@@ -511,3 +511,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
/// editor and the document keep every stored result.
|
||||
#[test]
|
||||
fn output_shows_fresh_seq_styleref_and_ref_results() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
|
||||
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Target words"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for (instr, result) in [
|
||||
(r#"SEQ Figure \* ARABIC"#, "7"),
|
||||
(r#"STYLEREF "Heading 1""#, "stale style"),
|
||||
("REF Mark \\h", "stale text"),
|
||||
("NUMWORDS", "999"),
|
||||
(r#"SEQ Table \* ARABIC"#, "9"),
|
||||
] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
// Stale stored results (inserting a field computes its result), the last field locked.
|
||||
let stored = ["7", "stale style", "stale text", "999", "9"];
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
let mut stale = stored.iter();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*result = stale.next().unwrap().to_string();
|
||||
*locked = stale.len() == 0;
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|pg| &pg.items)
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
|
||||
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
|
||||
let pdf = s.print_pdf(&json!({})).unwrap().0;
|
||||
assert!(pdf.len() > 1000);
|
||||
// Once the fields are current, output copies nothing.
|
||||
update_seq(&mut s).unwrap();
|
||||
update_fields(&mut s).unwrap();
|
||||
assert!(output_fields(&s.doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
|
||||
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
|
||||
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
|
||||
// and a REF to a bookmark in a box shows the box's text as stored.
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
|
||||
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
|
||||
let field = |instr: &str, stored: &str| {
|
||||
format!(
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
|
||||
run(stored)
|
||||
)
|
||||
};
|
||||
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
|
||||
let text_box = |inner: String| {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
};
|
||||
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
|
||||
let paras = [
|
||||
seq("A"),
|
||||
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
|
||||
seq("D"),
|
||||
format!("{}{}", seq("E"), text_box(seq("F"))),
|
||||
seq("G"),
|
||||
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
|
||||
];
|
||||
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
|
||||
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
|
||||
// The SEQ and REF results in reading order, text boxes where they are anchored.
|
||||
let mut results = Vec::new();
|
||||
out.objects_in_reading_order(&mut |o| {
|
||||
if let InlineObject::Field { instr, result, .. } = o {
|
||||
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
|
||||
}
|
||||
});
|
||||
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
|
||||
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
|
||||
assert_eq!(refs, ["B Fig 9"], "{results:?}");
|
||||
}
|
||||
|
||||
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
|
||||
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
|
||||
/// REF stored as "stale"), rather than Word's missing-bookmark error.
|
||||
#[test]
|
||||
fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
let mark = |name: &str, id: u32| {
|
||||
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
|
||||
};
|
||||
let field = |name: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
|
||||
let refs: String = names.iter().map(|n| field(n)).collect();
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
|
||||
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
|
||||
let footnotes =
|
||||
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
|
||||
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
|
||||
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
|
||||
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
|
||||
);
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let files = [
|
||||
("_rels/.rels", root.to_string()),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document),
|
||||
("word/header1.xml", header),
|
||||
("word/footer1.xml", footer),
|
||||
("word/footnotes.xml", footnotes),
|
||||
("word/endnotes.xml", endnotes),
|
||||
("word/comments.xml", comments),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, text) in &files {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
|
||||
}
|
||||
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
|
||||
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
|
||||
let mut results = Vec::new();
|
||||
for path in out.para_paths(StoryRef::Body) {
|
||||
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
results.push(result.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
#[test]
|
||||
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|it| match it {
|
||||
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
|
||||
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
|
||||
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
|
||||
}
|
||||
|
||||
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
|
||||
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
|
||||
/// text; a plain REF to the same bookmark is filled in.
|
||||
#[test]
|
||||
fn output_keeps_paragraph_number_references_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
|
||||
for instr in instrs {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "2.1".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the plain REF changes");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
|
||||
}
|
||||
|
||||
/// The results of the fields in the paragraph at `path` of `d`.
|
||||
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
|
||||
d.para(StoryRef::Body, path)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
|
||||
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = stale.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
last
|
||||
}
|
||||
|
||||
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
|
||||
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
|
||||
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
|
||||
/// rather than show the first paragraph's part alone.
|
||||
#[test]
|
||||
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
|
||||
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
|
||||
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
|
||||
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
|
||||
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
for instr in ["REF Span \\h", "REF Short \\h"] {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = stale_last_paragraph(&mut s, "stored");
|
||||
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
|
||||
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
|
||||
}
|
||||
|
||||
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
|
||||
/// in output and after F9: the numbers are filled in before the style references read them.
|
||||
#[test]
|
||||
fn a_style_reference_reads_the_headings_new_sequence_number() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
|
||||
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
|
||||
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
|
||||
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
|
||||
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
|
||||
// Stale stored results: the heading's number 9, the reference "stale".
|
||||
let heading = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "9".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let last = stale_last_paragraph(&mut s, "stale");
|
||||
let out = output_fields(&s.doc).expect("the fields change");
|
||||
assert_eq!(results_in(&out, &heading), ["1"]);
|
||||
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
|
||||
update_seq(&mut s).unwrap();
|
||||
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
|
||||
}
|
||||
|
||||
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
|
||||
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
|
||||
/// `bookmark_text` (F9's lookup) gives, or Word's error.
|
||||
#[test]
|
||||
fn output_gives_every_reference_its_bookmarks_text() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
|
||||
for (text, name) in [("First target", "A"), ("Second target", "B")] {
|
||||
s.run("select.text", &json!({"text": text})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
let targets = ["A", "B", "A", "Missing", "A"];
|
||||
for t in targets {
|
||||
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
|
||||
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
|
||||
for o in &mut para.objects {
|
||||
if let InlineObject::Field { result, .. } = o {
|
||||
*result = "stale".into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let results: Vec<String> = out
|
||||
.para(StoryRef::Body, &last)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
apply(s, &id)?;
|
||||
@@ -131,7 +131,7 @@ fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
@@ -189,7 +189,7 @@ pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
let mut sheet = (*s.doc.styles).clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
for child in Arc::make_mut(&mut s.doc.styles).styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
@@ -231,7 +231,7 @@ pub fn delete(s: &mut Session, id: &str) -> CmdResult {
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::Document;
|
||||
@@ -100,8 +101,8 @@ fn update_styles(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
/// What [`update_styles`] does to a document: all of `src`'s styles and its defaults.
|
||||
pub fn apply_template_styles(doc: &mut Document, src: &Document) -> Value {
|
||||
doc.styles.default_chr = src.styles.default_chr.clone();
|
||||
doc.styles.default_para = src.styles.default_para.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_chr = src.styles.default_chr.clone();
|
||||
Arc::make_mut(&mut doc.styles).default_para = src.styles.default_para.clone();
|
||||
let ids: Vec<String> = src.styles.styles.iter().map(|st| st.id.clone()).collect();
|
||||
copy_styles(doc, src, &ids)
|
||||
}
|
||||
@@ -172,7 +173,7 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if s.doc.styles.styles.iter().any(|o| o.id != st.id && o.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
|
||||
}
|
||||
if let Some(x) = s.doc.styles.get_mut(&st.id) {
|
||||
if let Some(x) = Arc::make_mut(&mut s.doc.styles).get_mut(&st.id) {
|
||||
x.name = name.clone();
|
||||
}
|
||||
touch_all(s);
|
||||
@@ -346,7 +347,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
};
|
||||
let builtin = doc.styles.get(&target).is_some_and(|d| d.builtin) || st.builtin;
|
||||
doc.styles.upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
Arc::make_mut(&mut doc.styles).upsert(Style { id: target.clone(), builtin, ..st.clone() });
|
||||
map.insert(st.id.clone(), target.clone());
|
||||
order.push((st.id.clone(), target));
|
||||
}
|
||||
@@ -366,7 +367,7 @@ pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value
|
||||
}
|
||||
n
|
||||
});
|
||||
if let Some(d) = doc.styles.get_mut(&target) {
|
||||
if let Some(d) = Arc::make_mut(&mut doc.styles).get_mut(&target) {
|
||||
d.based_on = based_on;
|
||||
d.next = next;
|
||||
d.linked = linked;
|
||||
@@ -389,8 +390,8 @@ fn import_num(doc: &mut Document, src: &Document, num: u32, map: &HashMap<String
|
||||
for l in &mut levels {
|
||||
l.style = l.style.as_deref().and_then(|s| map.get(s).cloned().or_else(|| doc.styles.get(s).map(|x| x.id.clone())));
|
||||
}
|
||||
doc.numbering.abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
doc.numbering.nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
Arc::make_mut(&mut doc.numbering).abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels, ..Default::default() });
|
||||
Arc::make_mut(&mut doc.numbering).nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() });
|
||||
done.insert(num, new_num);
|
||||
Some(new_num)
|
||||
}
|
||||
@@ -405,17 +406,17 @@ mod tests {
|
||||
/// "Box" (missing in the document) and a numbered "Steps" style.
|
||||
fn template() -> Document {
|
||||
let mut t = Document::new();
|
||||
if let Some(h) = t.styles.get_mut("Heading1") {
|
||||
if let Some(h) = Arc::make_mut(&mut t.styles).get_mut("Heading1") {
|
||||
h.chr.size = Some(31.0);
|
||||
}
|
||||
t.styles.upsert(Style {
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Box".into(),
|
||||
name: "Box".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
based_on: Some("Normal".into()),
|
||||
..Default::default()
|
||||
});
|
||||
t.styles.upsert(Style {
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Callout".into(),
|
||||
name: "Callout".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -423,8 +424,8 @@ mod tests {
|
||||
chr: CharProps { italic: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let num = t.numbering.add_list(ListKind::Numbered);
|
||||
t.styles.upsert(Style {
|
||||
let num = Arc::make_mut(&mut t.numbering).add_list(ListKind::Numbered);
|
||||
Arc::make_mut(&mut t.styles).upsert(Style {
|
||||
id: "Steps".into(),
|
||||
name: "Steps".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{
|
||||
Block, Border, BorderStyle, CmdError, CmdResult, ListKind, NumRef, OBJ, Path, Pos, Selection, Session, StoryRef, Table, Value, delete_selection,
|
||||
p, sel_result, split_para, type_text_effect,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes).
|
||||
fn autoformat(s: &mut Session, text: &str) -> Option<String> {
|
||||
@@ -164,7 +165,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
|
||||
let num = match found {
|
||||
Some(n) => n,
|
||||
None => s.doc.numbering.try_add_list(kind)?,
|
||||
None => Arc::make_mut(&mut s.doc.numbering).try_add_list(kind)?,
|
||||
};
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.delete(0, f.off)?;
|
||||
@@ -315,10 +316,11 @@ pub(super) fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style), with
|
||||
// no outline level: resolving drops the body-text level 9 and above.
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none()
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
@@ -357,14 +359,8 @@ pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result
|
||||
crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
if s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text) {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &prev, &before.props)?;
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
@@ -398,14 +394,8 @@ pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Resu
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
if let Some(before) = before {
|
||||
crate::cmd::fmt_revisions::record_para_props(s, at.story, &at.path, &before)?;
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
if p::bool(v, "default") == Some(true) {
|
||||
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
|
||||
s.doc.styles.default_chr.lang = Some(l.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some(l.clone());
|
||||
// A Normal style that names its own language follows the new default.
|
||||
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
|
||||
if let Some(n) = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "Normal")
|
||||
&& n.chr.lang.is_some()
|
||||
{
|
||||
n.chr.lang = Some(l);
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::{
|
||||
Block, CharProps, CmdError, CommandSpec, InlineObject, NumRef, Paragraph, Selection, StoryRef, Value, delete_selection, json, p, para_block,
|
||||
sel_result,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
|
||||
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
|
||||
@@ -299,8 +300,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.and_then(|x| x.props.numbering)
|
||||
.filter(|n| n.num != 0)
|
||||
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
|
||||
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
|
||||
let new = Arc::make_mut(&mut s.doc.numbering).restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
|
||||
if let Some(num) = Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|x| x.id == new) {
|
||||
num.start_overrides = vec![(n.level, value)];
|
||||
}
|
||||
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|q| *q >= f.path).collect();
|
||||
@@ -323,9 +324,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
|
||||
let text = p::str(v, "text").unwrap_or("%1.").to_string();
|
||||
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
|
||||
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
let id = Arc::make_mut(&mut s.doc.numbering).try_add_list(wordcraft_doc::ListKind::Numbered)?;
|
||||
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
|
||||
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(abs) = Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|x| x.id == a)
|
||||
&& let Some(l) = abs.levels.first_mut()
|
||||
{
|
||||
l.format = fmt;
|
||||
|
||||
@@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> {
|
||||
|
||||
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
if read && !s.view.read_mode {
|
||||
// Reading controls are session-only, and reset on each entry to the view.
|
||||
// Width, size and spacing reset on each entry. Page Color follows the saved preference.
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
s.view.read_page_color = s.prefs.read_page_color;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -256,6 +256,7 @@ fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
s.prefs.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
|
||||
"png" => Some(render_png(doc, 0, 2.0, opts)),
|
||||
"pdf" => {
|
||||
// Output shows the fields Word fills in as it prints (see `output_fields`).
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let pdf = wordcraft_pdf::PdfOptions {
|
||||
include_markup: opts.markup,
|
||||
comments: opts.markup && opts.comments,
|
||||
@@ -46,6 +49,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
|
||||
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
|
||||
/// markup area with the comments in balloons (page images always show tracked changes).
|
||||
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
|
||||
let out = crate::cmd::references::output_fields(doc);
|
||||
let doc = out.as_ref().unwrap_or(doc);
|
||||
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
|
||||
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
|
||||
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
|
||||
@@ -49,6 +49,9 @@ pub struct Prefs {
|
||||
/// Check spelling as you type (File › Options › Proofing). The live value is
|
||||
/// `ViewState::proofing`; this is the copy that is saved and restored (#1792).
|
||||
pub check_spelling: bool,
|
||||
/// Read Mode's Page Color. The live value is `ViewState::read_page_color`.
|
||||
#[serde(deserialize_with = "lenient_read_page_color")]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub ignore_uppercase: bool,
|
||||
pub ignore_numbers: bool,
|
||||
pub ignore_internet: bool,
|
||||
@@ -74,6 +77,7 @@ impl Default for Prefs {
|
||||
marks: Default::default(),
|
||||
mark_grammar: true,
|
||||
check_spelling: true,
|
||||
read_page_color: Default::default(),
|
||||
ignore_uppercase: true,
|
||||
ignore_numbers: true,
|
||||
ignore_internet: true,
|
||||
@@ -100,3 +104,30 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
|
||||
}
|
||||
|
||||
/// Ignore an invalid saved Page Color without discarding other preferences.
|
||||
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
|
||||
let value = serde_json::Value::deserialize(d)?;
|
||||
Ok(serde_json::from_value(value).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Prefs;
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
|
||||
#[test]
|
||||
fn read_page_color_loads_from_old_and_damaged_prefs() {
|
||||
let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap();
|
||||
assert_eq!(old.read_page_color, ReadPageColor::None);
|
||||
assert!(!old.check_spelling);
|
||||
for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] {
|
||||
let prefs: Prefs = serde_json::from_str(bad).unwrap();
|
||||
assert_eq!(prefs.read_page_color, ReadPageColor::None);
|
||||
assert!(!prefs.check_spelling);
|
||||
}
|
||||
let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap();
|
||||
assert_eq!(saved.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! WordCraft prints through a PDF (the host opens it in the system's print flow), so the settings
|
||||
//! that can be delivered are the ones that shape the PDF: the page range, odd/even pages, copies
|
||||
//! and collation. Printer choice, duplex and pages per sheet belong to the OS print dialog.
|
||||
//! and collation. Physical sheet imposition is shared with the preview; printer choice and duplex
|
||||
//! are handled by the host.
|
||||
//!
|
||||
//! Page ranges and copies come from users and control channels, so they are bounded: a range
|
||||
//! text is at most [`MAX_SPEC_CHARS`] long, copies are clamped to [`MAX_COPIES`], and the
|
||||
@@ -10,6 +11,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_layout::DocLayout;
|
||||
pub use wordcraft_pdf::imposition::Imposition;
|
||||
|
||||
use crate::{CmdError, Session, p};
|
||||
|
||||
@@ -146,6 +148,47 @@ pub fn paper(v: &Value) -> Result<Option<(&'static str, f32, f32)>, CmdError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the physical sheet layout without touching document page setup.
|
||||
pub fn imposition(v: &Value) -> Result<wordcraft_pdf::imposition::Imposition, CmdError> {
|
||||
match v.get("pagesPerSheet") {
|
||||
None => Ok(Imposition::Single),
|
||||
Some(Value::Number(n)) => match n.as_u64() {
|
||||
Some(1) => Ok(Imposition::Single),
|
||||
Some(n @ (2 | 4 | 6 | 9 | 16)) => Ok(Imposition::NUp(n as u8)),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
},
|
||||
Some(Value::String(s)) if s == "booklet" || s == "bookletReverse" => Ok(Imposition::Booklet { reverse: s == "bookletReverse" }),
|
||||
_ => Err(CmdError::Params("pagesPerSheet must be 1, 2, 4, 6, 9, 16, booklet or bookletReverse".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve physical paper orientation for imposed jobs; ordinary export keeps its existing policy.
|
||||
fn imposed_paper(v: &Value, lay: &DocLayout, pages: &[usize]) -> Result<Option<(f32, f32)>, CmdError> {
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
if imposition(v)? == Imposition::Single {
|
||||
return Ok(paper);
|
||||
}
|
||||
let own = pages.first().and_then(|p| lay.pages.get(*p)).map(|p| (p.w - p.markup, p.h));
|
||||
let Some((w, h)) = paper.or(own) else { return Ok(paper) };
|
||||
match v.get("orientation").and_then(Value::as_str).unwrap_or("auto") {
|
||||
"auto" => Ok(paper),
|
||||
"portrait" => Ok(Some((w.min(h), w.max(h)))),
|
||||
"landscape" => Ok(Some((w.max(h), w.min(h)))),
|
||||
_ => Err(CmdError::Params("orientation must be auto, portrait or landscape".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan physical sheet sides for one copy, using the same selection and geometry as printing.
|
||||
pub fn sheets(v: &Value, lay: &DocLayout, current: (usize, usize)) -> Result<Vec<wordcraft_pdf::imposition::Sheet>, CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let pages = plan(&single, lay.pages.len(), current)?.pages;
|
||||
imposition(v)?.sheets(lay, &pages, imposed_paper(v, lay, &pages)?).map_err(|e| CmdError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The pages the caret (or selection) is on, in the output layout: (first, last).
|
||||
pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) {
|
||||
@@ -169,14 +212,26 @@ impl Session {
|
||||
self.print_pdf_with(v, false)
|
||||
}
|
||||
|
||||
/// Render a shared sheet plan for a saved PDF or an annotation-free printer job.
|
||||
fn print_pdf_with(&self, v: &Value, links: bool) -> Result<(Vec<u8>, PrintPlan), CmdError> {
|
||||
if !v.is_object() {
|
||||
return Err(CmdError::Params("print settings must be an object".into()));
|
||||
}
|
||||
// Print Markup (File › Print) decides whether tracked changes and comments print.
|
||||
let markup = crate::cmd::file::export_options(self, v);
|
||||
let lay = self.export_layout_with(markup.markup);
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
let doc = out.as_ref().unwrap_or(&self.doc);
|
||||
let lay = self.export_layout_of(doc, markup.markup);
|
||||
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let paper = paper(v)?.map(|(_, w, h)| (w, h));
|
||||
let mut single = v.clone();
|
||||
single["copies"] = json!(1);
|
||||
let single_plan = self::plan(&single, lay.pages.len(), self.current_pages(&lay))?;
|
||||
let paper = imposed_paper(v, &lay, &single_plan.pages)?;
|
||||
let opts = wordcraft_pdf::PdfOptions {
|
||||
pages: Some(plan.pages.clone()),
|
||||
pages: Some(single_plan.pages),
|
||||
imposition: imposition(v)?,
|
||||
sheet_copies: plan.copies,
|
||||
sheet_collate: p::bool(v, "collate").unwrap_or(true),
|
||||
// Repeated pages would repeat the structure tree's entries too.
|
||||
tagged: plan.copies <= 1,
|
||||
include_markup: markup.markup,
|
||||
@@ -185,7 +240,7 @@ impl Session {
|
||||
links,
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
Ok((bytes, plan))
|
||||
}
|
||||
}
|
||||
@@ -340,3 +395,31 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod imposition_tests {
|
||||
use super::*;
|
||||
/// Sheet printing leaves document bytes, dirty state, selection and undo history untouched.
|
||||
#[test]
|
||||
fn print_imposition_is_pure_and_rejects_hostile_modes() {
|
||||
let s = Session::new(wordcraft_doc::Document::from_text("one\u{c}two\u{c}three\u{c}four\u{c}five"));
|
||||
let before = (s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap());
|
||||
for (mode, sides) in
|
||||
[(json!(2), 3), (json!(4), 2), (json!(6), 1), (json!(9), 1), (json!(16), 1), (json!("booklet"), 4), (json!("bookletReverse"), 4)]
|
||||
{
|
||||
let params = json!({"pagesPerSheet":mode, "copies":2});
|
||||
let lay = s.export_layout();
|
||||
assert_eq!(sheets(¶ms, &lay, s.current_pages(&lay)).unwrap().len(), sides);
|
||||
let (bytes, plan) = s.print_pdf(¶ms).unwrap();
|
||||
assert_eq!(plan.copies, 2);
|
||||
assert_eq!(String::from_utf8_lossy(&bytes).matches("/MediaBox[").count(), sides * 2);
|
||||
}
|
||||
for bad in [json!(0), json!(3), json!(-1), json!(1.5), json!(true), json!(null), json!({}), json!("2"), json!("x".repeat(10000))] {
|
||||
assert!(s.print_pdf(&json!({"pagesPerSheet":bad})).is_err());
|
||||
}
|
||||
for params in [json!(null), json!([]), json!(false)] {
|
||||
assert!(s.print_pdf(¶ms).is_err());
|
||||
}
|
||||
assert_eq!((s.rev(), s.dirty, s.sel.clone(), serde_json::to_value(&s.doc).unwrap()), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Built-in sample documents and templates (original text, generated in code).
|
||||
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block};
|
||||
@@ -50,10 +51,10 @@ pub fn sample_document() -> Document {
|
||||
quote.props.space_before = Some(12.0);
|
||||
b.push(Block::Para(quote));
|
||||
b.push(Block::Para(styled("What we agree on", "Heading2")));
|
||||
let mut num = d.numbering.clone();
|
||||
let mut num = (*d.numbering).clone();
|
||||
let bullets = num.add_list(ListKind::Bullet);
|
||||
let steps = num.add_list(ListKind::Numbered);
|
||||
d.numbering = num;
|
||||
d.numbering = Arc::new(num);
|
||||
for (lead, rest) in [
|
||||
("Clean as you go. ", "Leave a table the way you would like to find it."),
|
||||
("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."),
|
||||
|
||||
@@ -385,6 +385,7 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -459,6 +460,7 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -161,8 +161,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
|
||||
/// area with the comments in balloons.
|
||||
/// area with the comments in balloons. Fields Word fills in for output show their fresh
|
||||
/// results ([`crate::cmd::references::output_fields`]).
|
||||
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
|
||||
let out = crate::cmd::references::output_fields(&self.doc);
|
||||
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
|
||||
}
|
||||
|
||||
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
|
||||
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
|
||||
let (fn_name, fn_path) = self.resolved_filenames();
|
||||
let opts = LayoutOptions {
|
||||
view: ViewMode::Print,
|
||||
@@ -172,7 +179,7 @@ impl Session {
|
||||
filepath: Some(Arc::from(fn_path.as_str())),
|
||||
..Default::default()
|
||||
};
|
||||
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
|
||||
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
|
||||
}
|
||||
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
@@ -199,7 +206,7 @@ impl Session {
|
||||
self.editing_lang = tag;
|
||||
let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty();
|
||||
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
|
||||
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
Arc::make_mut(&mut self.doc.styles).default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
// Not an edit: the document is as untouched as it was.
|
||||
if self.made_rev == self.rev.wrapping_sub(1) {
|
||||
|
||||
@@ -31,6 +31,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,6 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
+244
-35
@@ -4,6 +4,7 @@ use wordcraft_proof::cs::install_dictionary;
|
||||
mod czech_test_dict;
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::BorderStyle;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -2570,19 +2571,22 @@ fn select_extend_from_a_table_paragraph_selects_the_document() {
|
||||
assert_eq!(b, s.doc.end_of(StoryRef::Body));
|
||||
}
|
||||
|
||||
/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table
|
||||
/// in the deepest cell says so; a table pasted there brings its text as paragraphs.
|
||||
/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot)
|
||||
/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text
|
||||
/// as paragraphs.
|
||||
#[test]
|
||||
fn tables_nest_at_most_24_deep() {
|
||||
fn tables_nest_only_as_deep_as_a_json_save_reads() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err();
|
||||
assert!(e.to_string().contains("24 deep"), "{e}");
|
||||
assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "edit.paste", json!({"text": "b", "html": "<table><tr><td>b</td></tr></table>"}));
|
||||
let cell = s.sel.focus.path.clone();
|
||||
@@ -2591,6 +2595,44 @@ fn tables_nest_at_most_24_deep() {
|
||||
assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there");
|
||||
}
|
||||
|
||||
/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads
|
||||
/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and
|
||||
/// gradient text effects changed with Track Changes on (the change keeps the old effects too).
|
||||
#[test]
|
||||
fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() {
|
||||
use wordcraft_doc::table::MAX_TABLE_DEPTH;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "word"}));
|
||||
run(&mut s, "select.text", json!({"text": "word"}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": true}));
|
||||
run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}}));
|
||||
run(&mut s, "review.trackChanges", json!({"on": false}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "insert.textBox", json!({"text": "in box"}));
|
||||
for _ in 0..MAX_TABLE_DEPTH {
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
}
|
||||
assert_ne!(s.sel.focus.story, StoryRef::Body);
|
||||
assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH);
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
// Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did.
|
||||
let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap();
|
||||
let gradient = |f: &Option<wordcraft_doc::fill::Fill>| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_)));
|
||||
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}");
|
||||
assert!(
|
||||
para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())),
|
||||
"{para:?}"
|
||||
);
|
||||
let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap();
|
||||
assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true));
|
||||
}
|
||||
|
||||
/// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a
|
||||
/// long sentence widens (wrapping within the page), and the widths are written to the grid.
|
||||
#[test]
|
||||
@@ -4356,7 +4398,7 @@ fn toc_page_numbers_reach_the_right_margin() {
|
||||
let long = "A deliberately long section title that does not fit on one line of the table of contents and wraps onto a second line";
|
||||
let mut d = wordcraft_doc::Document::from_text(&format!("Contents\n\nNo entries.\n\nShort\n{long}\nTop\nAppendix"));
|
||||
{
|
||||
let toc2 = d.styles.get_mut("TOC2").unwrap();
|
||||
let toc2 = Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap();
|
||||
toc2.para.tabs = Some(vec![
|
||||
TabStop { pos: 77.0, align: TabAlign::Left, leader: TabLeader::None },
|
||||
TabStop { pos: 467.2, align: TabAlign::Right, leader: TabLeader::Dot },
|
||||
@@ -4493,7 +4535,7 @@ fn toc_entries_carry_heading_numbers() {
|
||||
run(&mut s, "para.heading1", json!({}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
}
|
||||
let num = s.doc.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let num = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for b in s.doc.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
|
||||
&& p.props.style.as_deref() == Some("Heading1")
|
||||
@@ -4593,13 +4635,41 @@ fn hostile_params_never_panic() {
|
||||
for j in &junk {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech");
|
||||
let _ = s.run(spec.id, j);
|
||||
let r = s.run(spec.id, j);
|
||||
assert!(!caught_panic(®, &r), "{} panicked on {j}: {r:?}", spec.id);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
|
||||
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
|
||||
/// those words.
|
||||
fn caught_panic(reg: &crate::Registry, r: &Result<serde_json::Value, crate::CmdError>) -> bool {
|
||||
matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caught_panic_flags_a_command_that_panics() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure();
|
||||
// Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207).
|
||||
let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure();
|
||||
s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer]));
|
||||
let boom = s.run("test.boom", &json!({}));
|
||||
assert!(caught_panic(&s.registry, &boom));
|
||||
let nested = s.run("test.outer", &json!({}));
|
||||
assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}");
|
||||
assert!(caught_panic(&s.registry, &nested));
|
||||
assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into()))));
|
||||
// A command's own error can quote what it was given.
|
||||
let mut full = Session::new(crate::sample::sample_document());
|
||||
let echoed = full.run("developer.control", &json!({"control": "x panicked: y"}));
|
||||
assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}");
|
||||
assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_structure() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
@@ -4766,10 +4836,10 @@ fn commands_after_the_largest_ids_take_free_ones() {
|
||||
s.doc.parts.insert(id, wordcraft_doc::Part { kind: wordcraft_doc::PartKind::Comment, blocks, ..Default::default() });
|
||||
s.doc.comments.insert(id, wordcraft_doc::Comment { part: id, ..Default::default() });
|
||||
}
|
||||
s.doc.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
s.doc.numbering.abstracts[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].id = u32::MAX;
|
||||
s.doc.numbering.nums[0].abstract_id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].id = u32::MAX;
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].abstract_id = u32::MAX;
|
||||
let before = s.doc.clone();
|
||||
assert_eq!(run(&mut s, "review.newComment", json!({"text": "new"})), json!({"id": 1, "story": 1}));
|
||||
assert_eq!(run(&mut s, "review.reply", json!({"id": 0, "text": "re"})), json!({"id": 2}));
|
||||
@@ -4866,7 +4936,7 @@ fn word_count_includes_text_boxes_by_default() {
|
||||
#[test]
|
||||
fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
|
||||
let mut d = wordcraft_doc::Document::new();
|
||||
let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let num_id = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 });
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))];
|
||||
@@ -5839,6 +5909,111 @@ fn modify_style_takes_hex_colours_like_format_color() {
|
||||
assert_eq!(color(&s), Some(TextColor::Rgb(Rgb(0x1B, 0x16, 0x11))), "the hex string also reads inside {{\"Rgb\": …}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_steps_share_the_style_sheet_and_lists_until_a_command_changes_them() {
|
||||
// Every undo step copied the whole style sheet and list definitions: kilobytes a style, so
|
||||
// typing into a document with hundreds of styles cost megabytes a step.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("One two three."));
|
||||
for _ in 0..5 {
|
||||
run(&mut s, "text.insert", json!({"text": "word "}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_labels().len(), 5, "five undo steps");
|
||||
assert!(Arc::strong_count(&s.doc.styles) >= 6, "the document and each step hold one style sheet");
|
||||
assert!(Arc::strong_count(&s.doc.numbering) >= 6, "and one set of list definitions");
|
||||
// A command that changes a style copies the sheet once; undo puts the shared one back.
|
||||
let sheet = Arc::clone(&s.doc.styles);
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"bold": true}}));
|
||||
assert!(!Arc::ptr_eq(&sheet, &s.doc.styles));
|
||||
assert_ne!(*sheet, *s.doc.styles, "the change is in the new copy only");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&sheet, &s.doc.styles), "undo restores the step's own sheet");
|
||||
// Lists likewise.
|
||||
let lists = Arc::clone(&s.doc.numbering);
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
assert!(!Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
assert!(s.doc.numbering.nums.len() > lists.nums.len());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(Arc::ptr_eq(&lists, &s.doc.numbering));
|
||||
}
|
||||
|
||||
/// The same at scale: 1,000 edits of a document with 600 styles and 60 lists of nine levels. The
|
||||
/// history keeps the newest `MAX_UNDO` steps and drops the oldest. Its steps hold one style sheet
|
||||
/// for each style change they span, however many steps there are, and one set of lists. A sheet
|
||||
/// that only dropped steps held is freed.
|
||||
#[test]
|
||||
fn a_thousand_edits_of_a_large_document_keep_the_newest_steps_and_share_its_styles() {
|
||||
use crate::doc_state::MAX_UNDO;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Weak;
|
||||
|
||||
let body: Vec<String> = (0..200).map(|i| format!("Paragraph {i} of the body, with a few words in it.")).collect();
|
||||
let mut doc = wordcraft_doc::Document::from_text(&body.join("\n"));
|
||||
let sheet = Arc::make_mut(&mut doc.styles);
|
||||
let normal = sheet.get("Normal").cloned().unwrap();
|
||||
for i in 0..600 {
|
||||
let (id, name) = (format!("Custom{i}"), format!("Custom {i}"));
|
||||
sheet.upsert(wordcraft_doc::Style {
|
||||
id,
|
||||
name,
|
||||
based_on: Some("Normal".into()),
|
||||
builtin: false,
|
||||
default: false,
|
||||
quick: false,
|
||||
..normal.clone()
|
||||
});
|
||||
}
|
||||
for _ in 0..60 {
|
||||
Arc::make_mut(&mut doc.numbering).add_list(wordcraft_doc::ListKind::Legal);
|
||||
}
|
||||
let mut s = Session::new(doc);
|
||||
|
||||
// An edit types a word, except every hundredth, which changes a style: one undo step each.
|
||||
// After each edit: the text, and the sheet and lists the document then had.
|
||||
let edits = 1000;
|
||||
let mut after = Vec::with_capacity(edits);
|
||||
let mut probe = Weak::new();
|
||||
for i in 0..edits {
|
||||
if i % 100 == 50 {
|
||||
run(&mut s, "styles.modify", json!({"style": "Normal", "chr": {"size": 10 + i / 100}}));
|
||||
} else {
|
||||
run(&mut s, "text.insert", json!({"text": format!("w{i} ")}));
|
||||
run(&mut s, "caret.left", json!({}));
|
||||
}
|
||||
after.push((text(&s), Arc::downgrade(&s.doc.styles), Arc::downgrade(&s.doc.numbering)));
|
||||
// Edit 50's sheet is replaced at edit 150, so the step taken before edit 150 is the last
|
||||
// to hold it. That step is the oldest one kept until edit 650 pushes it out.
|
||||
match i {
|
||||
50 => probe = Arc::downgrade(&s.doc.styles),
|
||||
649 => assert!(probe.upgrade().is_some(), "a kept step still holds edit 50's sheet"),
|
||||
650 => assert!(probe.upgrade().is_none(), "dropping the last step that held edit 50's sheet frees it"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.undo_steps_so_far(), edits as u64, "one undo step an edit");
|
||||
assert_eq!(s.undo_depth(), MAX_UNDO);
|
||||
|
||||
// Undo goes back through the newest steps, newest first. Each restores the text and the very
|
||||
// sheet and lists the document had after that edit: the steps share them, not copies.
|
||||
let (mut sheets, mut lists) = (HashSet::new(), HashSet::new());
|
||||
for k in 0..=MAX_UNDO {
|
||||
let (txt, sheet, numbering) = &after[edits - 1 - k];
|
||||
assert_eq!(&text(&s), txt, "after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.styles, &sheet.upgrade().unwrap()), "the sheet after {k} undos");
|
||||
assert!(Arc::ptr_eq(&s.doc.numbering, &numbering.upgrade().unwrap()), "the lists after {k} undos");
|
||||
sheets.insert(Arc::as_ptr(&s.doc.styles));
|
||||
lists.insert(Arc::as_ptr(&s.doc.numbering));
|
||||
if k < MAX_UNDO {
|
||||
assert!(s.undo(), "undo {}", k + 1);
|
||||
}
|
||||
}
|
||||
assert!(!s.undo(), "the oldest 500 steps were dropped: edits 0 to 499 can't be undone");
|
||||
// The 501 states, the document's and the kept steps', hold six sheets (edit 450's, and those
|
||||
// of the changes at 550 to 950) and one set of lists.
|
||||
assert_eq!(sheets.len(), 6);
|
||||
assert_eq!(lists.len(), 1);
|
||||
}
|
||||
|
||||
/// #865: the other colours in CharProps and ParaProps (`underlineColor`, `shading`, a border's
|
||||
/// `color`) take `RRGGBB` / `#RRGGBB` like `para.borders` and `table.shading`, and still `[r, g, b]`.
|
||||
#[test]
|
||||
@@ -6560,7 +6735,7 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
/// document alone, starts at None and is kept between documents and runs as a preference.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
@@ -6571,18 +6746,46 @@ fn reading_page_color_is_a_transient_view_setting() {
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse);
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
// The color stays for the next entry to Read Mode, while the other reading controls reset.
|
||||
run(&mut s, "view.readTextSize", json!({"value": 150}));
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
assert_eq!(s.view.read_zoom, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_page_color_follows_saved_prefs_and_new_documents() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
s.prefs.read_page_color = ReadPageColor::Sepia;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
let first = s.document_id();
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
s.activate(first).unwrap();
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
run(&mut s, "file.open", json!({"path": "reading-color.docx", "data": ""}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
s.set_document(wordcraft_doc::Document::new());
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
@@ -7088,7 +7291,7 @@ fn deleting_a_table_style_from_manage_styles_uses_table_style_deletion() {
|
||||
assert_eq!((r["deleted"][0].as_str(), r["restyled"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert_eq!(s.doc.table(StoryRef::Body, &tp).unwrap().props.style.as_deref(), Some("TableGrid"));
|
||||
assert_eq!(s.doc.styles.table_style("Child").unwrap().parts.header_fill, Some(Rgb(0xC0, 0, 0)), "the child keeps its look");
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
assert!(s.run("styles.delete", &json!({"style": "Table Grid"})).is_err());
|
||||
@@ -7233,7 +7436,7 @@ fn delete_table_style_falls_back_and_undoes() {
|
||||
fn builtin_table_styles_marked_custom_in_a_docx_cant_be_deleted() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
|
||||
for st in s.doc.styles.styles.iter_mut() {
|
||||
for st in Arc::make_mut(&mut s.doc.styles).styles.iter_mut() {
|
||||
st.builtin = false;
|
||||
}
|
||||
for name in ["Table Grid", "Normal Table", "TableGrid"] {
|
||||
@@ -7845,7 +8048,7 @@ fn list_buttons_follow_the_paragraphs_own_level() {
|
||||
// A bulleted list whose second level is numbered: at level 2 the paragraph is numbered.
|
||||
let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num;
|
||||
let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() };
|
||||
s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
Arc::make_mut(&mut s.doc.numbering).nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered));
|
||||
run(&mut s, "para.listLevel", json!({"level": 1}));
|
||||
assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false });
|
||||
run(&mut s, "para.numbering", json!({}));
|
||||
@@ -9232,7 +9435,7 @@ fn default_proofing_language_survives_a_restart() {
|
||||
assert_eq!(second.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
|
||||
|
||||
let mut english = wordcraft_doc::Document::from_text("English document");
|
||||
english.styles.default_chr.lang = Some("en-GB".into());
|
||||
Arc::make_mut(&mut english.styles).default_chr.lang = Some("en-GB".into());
|
||||
let bytes = crate::io::save_bytes("english.docx", &english).unwrap();
|
||||
let data = crate::cmd::insert::base64_encode(&bytes);
|
||||
run(&mut second, "file.open", json!({"path": "english.docx", "data": data}));
|
||||
@@ -9875,7 +10078,7 @@ fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 1}));
|
||||
run(&mut s, "text.insert", json!({"text": "3,50"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
@@ -10538,8 +10741,14 @@ fn inspect_document_hidden_text_in_every_story_and_from_styles() {
|
||||
run(&mut s, "insert.footer", json!({"text": "f"}));
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
let hidden = CharProps { hidden: Some(true), ..Default::default() };
|
||||
s.doc.styles.upsert(Style { id: "HideC".into(), name: "Hide C".into(), kind: StyleKind::Character, chr: hidden.clone(), ..Default::default() });
|
||||
s.doc.styles.upsert(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideC".into(),
|
||||
name: "Hide C".into(),
|
||||
kind: StyleKind::Character,
|
||||
chr: hidden.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideP".into(),
|
||||
name: "Hide P".into(),
|
||||
kind: StyleKind::Paragraph,
|
||||
@@ -10650,7 +10859,7 @@ fn inspect_document_removes_text_hidden_by_document_defaults() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Paragraph, para_block};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.hidden = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.hidden = Some(true);
|
||||
s.doc.body = vec![
|
||||
para_block(Paragraph::with_text("hide-default", CharProps::default())),
|
||||
para_block(Paragraph::with_text("keep-default", CharProps { hidden: Some(false), ..Default::default() })),
|
||||
@@ -10693,7 +10902,7 @@ fn inspect_document_keeps_content_control_markers() {
|
||||
fn inspect_document_lists_tables_whose_style_hides_text() {
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
|
||||
let mut s = s();
|
||||
s.doc.styles.upsert(Style {
|
||||
Arc::make_mut(&mut s.doc.styles).upsert(Style {
|
||||
id: "HideHeader".into(),
|
||||
name: "Hide Header".into(),
|
||||
kind: StyleKind::Table,
|
||||
@@ -11287,8 +11496,8 @@ fn toc_keeps_direct_number_tabs() {
|
||||
d.last_section.page_w = 220.0;
|
||||
d.last_section.margin_left = 20.0;
|
||||
d.last_section.margin_right = 20.0;
|
||||
d.styles.get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
Arc::make_mut(&mut d.styles).get_mut("TOC2").unwrap().para.tabs = Some(vec![right]);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
{
|
||||
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
p.props.style = Some("TOCHeading".into());
|
||||
@@ -11590,11 +11799,11 @@ fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
|
||||
body.chr.size = Some(20.0);
|
||||
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
|
||||
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
|
||||
s.doc.styles.styles.push(body.clone());
|
||||
s.doc.styles.styles.push(grid.clone());
|
||||
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
|
||||
s.doc.styles.default_chr.lang = Some("de-DE".into());
|
||||
s.doc.styles.default_chr.size = Some(11.0);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(body.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).styles.push(grid.clone());
|
||||
Arc::make_mut(&mut s.doc.styles).latent_styles = Some("<w:latentStyles/>".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("de-DE".into());
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = Some(11.0);
|
||||
run(&mut s, "design.styleSet", json!({ "name": name }));
|
||||
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
|
||||
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
|
||||
@@ -11664,8 +11873,8 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.size = None;
|
||||
s.doc.styles.default_chr.size_cs = Some(10.0);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(10.0);
|
||||
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
|
||||
ch.chr.size = Some(20.0);
|
||||
ch.chr.size_cs = Some(22.0);
|
||||
@@ -11674,7 +11883,7 @@ fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
parts.header_chr.size = Some(14.0);
|
||||
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
|
||||
tb.table = Some(parts);
|
||||
s.doc.styles.styles.extend([ch, tb]);
|
||||
Arc::make_mut(&mut s.doc.styles).styles.extend([ch, tb]);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
let st = &s.doc.styles;
|
||||
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
|
||||
@@ -11836,7 +12045,7 @@ fn alt_chunks_open_in_place_as_word_brings_them_in() {
|
||||
fn text_from_file_brings_the_files_pictures() {
|
||||
let dir = scratch_dir("text-from-file-pictures");
|
||||
let mut src = Session::new(wordcraft_doc::Document::new());
|
||||
let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
let list = Arc::make_mut(&mut src.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
// A list item, then a paragraph (a single paragraph would take the caret's formatting).
|
||||
src.doc.split_paragraph(&Pos::body(0, 0)).unwrap();
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
|
||||
@@ -11851,7 +12060,7 @@ fn text_from_file_brings_the_files_pictures() {
|
||||
let mut s = s();
|
||||
// A picture and a list of our own first, so the file's key and list id would point at ours.
|
||||
let ours = s.doc.add_media(vec![9, 9, 9], "png");
|
||||
let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
let our_list = Arc::make_mut(&mut s.doc.numbering).add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let shown: Vec<String> = s
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open.
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::Align;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -118,9 +119,9 @@ fn font_commands_override_inherited_complex_script_properties_explicitly() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
|
||||
let mut s = session("שלום ABC", true);
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.default_chr.bold_cs = Some(true);
|
||||
s.doc.styles.default_chr.italic_cs = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.size_cs = Some(30.0);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.bold_cs = Some(true);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.italic_cs = Some(true);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": false}));
|
||||
run(&mut s, "format.italic", json!({"value": false}));
|
||||
|
||||
@@ -59,15 +59,16 @@ fn relative_font_sizes_resolve_styles_and_preserve_other_properties() {
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
|
||||
let mut s = mixed();
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.upsert(Style {
|
||||
let styles = std::sync::Arc::make_mut(&mut s.doc.styles);
|
||||
styles.default_chr.size_cs = Some(30.0);
|
||||
styles.upsert(Style {
|
||||
id: "Large".into(),
|
||||
name: "Large".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
chr: CharProps { size: Some(20.0), bold: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
s.doc.styles.upsert(Style {
|
||||
styles.upsert(Style {
|
||||
id: "Small".into(),
|
||||
name: "Small".into(),
|
||||
kind: StyleKind::Character,
|
||||
|
||||
@@ -192,3 +192,280 @@ fn a_subset_font_word_embeds_lays_out_text_set_in_its_table_name() {
|
||||
let x = end_x(&docx_embedding(FAMILY, Some((&subset, "{6A0E3B57-1C2D-4E8F-9A7B-3D5C1E0F2B64}")), TEXT));
|
||||
assert!((x - mono).abs() < 0.01, "measured with the embedded subset: {x} vs {mono} (missing {missing})");
|
||||
}
|
||||
|
||||
/// `font.missing`'s fonts, as "font>shownAs".
|
||||
fn missing_list(s: &mut Session) -> Vec<String> {
|
||||
let m = s.run("font.missing", &json!({})).unwrap();
|
||||
m["missing"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let shown: Vec<&str> = f["shownAs"].as_array().unwrap().iter().map(|x| x.as_str().unwrap()).collect();
|
||||
format!("{}>{}", f["font"].as_str().unwrap(), shown.join(","))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The missing fonts of `doc` as laid out.
|
||||
fn missing_in(doc: wordcraft_doc::Document) -> Vec<crate::cmd::font::MissingFont> {
|
||||
let mut s = Session::new(doc);
|
||||
let layout = s.layout();
|
||||
crate::cmd::font::missing_fonts(&s.doc, &layout)
|
||||
}
|
||||
|
||||
/// A paragraph of `text` set in `font`.
|
||||
fn para_in(text: &str, font: &str) -> wordcraft_doc::Paragraph {
|
||||
wordcraft_doc::Paragraph::with_text(text, wordcraft_doc::CharProps { font: Some(font.into()), ..Default::default() })
|
||||
}
|
||||
|
||||
/// A document of these paragraphs.
|
||||
fn doc_of(paras: Vec<wordcraft_doc::Paragraph>) -> wordcraft_doc::Document {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.body = paras.into_iter().map(wordcraft_doc::para_block).collect();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_that_are_not_installed_are_reported_with_the_font_shown_instead() {
|
||||
// A family no system has, with no Serif/Mono in its name: it's drawn in a sans substitute.
|
||||
const MISSING: &str = "Wordcraft Nowhere Face";
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Bundled\nMissing"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null(), "a bundled font is installed");
|
||||
assert_eq!(missing_list(&mut s), Vec::<String>::new());
|
||||
|
||||
s.run("select.text", &json!({"text": "Missing"})).unwrap();
|
||||
s.run("format.font", &json!({"name": MISSING})).unwrap();
|
||||
let st = s.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["font"], MISSING);
|
||||
let shown = st["fontShownAs"].as_str().unwrap_or_default().to_string();
|
||||
assert!(!shown.is_empty() && shown != MISSING, "the font box learns the substitute: {st}");
|
||||
assert_eq!(missing_list(&mut s), [format!("{MISSING}>{shown}")], "only the missing font, once");
|
||||
|
||||
// A mixed selection has no single font, so nothing to flag in the font box.
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_only_a_style_names_is_not_reported_missing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Unused Look"})).unwrap();
|
||||
s.run("styles.modify", &json!({"style": "Unused Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
|
||||
// Creating a style applies it, as in Word: put the text back in Normal.
|
||||
s.run("para.style", &json!({"style": "Normal"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "JetBrains Mono"})).unwrap();
|
||||
assert_eq!(missing_list(&mut s), Vec::<String>::new(), "no text uses the style");
|
||||
// Text in the style, with no font of its own, is.
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "Body"})).unwrap();
|
||||
s.run("styles.create", &json!({"name": "Used Look"})).unwrap();
|
||||
s.run("styles.modify", &json!({"style": "Used Look", "chr": {"font": "Wordcraft Style Only Face"}})).unwrap();
|
||||
s.run("para.style", &json!({"style": "Used Look"})).unwrap();
|
||||
let list = missing_list(&mut s);
|
||||
assert!(list.iter().any(|f| f.starts_with("Wordcraft Style Only Face>")), "{list:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_are_what_layout_drew_in_shown_text() {
|
||||
let mut doc = doc_of(vec![
|
||||
para_in("one", "Wordcraft Nowhere Face"),
|
||||
para_in("two", "wordcraft nowhere face"),
|
||||
para_in("three", "JetBrains Mono"),
|
||||
// Spaces, a picture's placeholder and zero-width characters show no font.
|
||||
para_in(" \u{FFFC}\t\u{200B}\u{FEFF}\u{FE0F}\u{3164}\u{034F}", "Wordcraft Blank Face"),
|
||||
]);
|
||||
// Hidden text isn't laid out.
|
||||
doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(
|
||||
"secret",
|
||||
wordcraft_doc::CharProps { font: Some("Wordcraft Hidden Face".into()), hidden: Some(true), ..Default::default() },
|
||||
)));
|
||||
// A text box story nothing shows any more (deleted, not yet pruned).
|
||||
doc.parts.insert(
|
||||
77,
|
||||
wordcraft_doc::Part {
|
||||
kind: wordcraft_doc::PartKind::TextBox,
|
||||
blocks: vec![wordcraft_doc::para_block(para_in("gone", "Wordcraft Orphan Face"))],
|
||||
body: Default::default(),
|
||||
},
|
||||
);
|
||||
let names: Vec<String> = missing_in(doc).into_iter().map(|f| f.font).collect();
|
||||
assert_eq!(names.len(), 1, "one font whatever its case, and nothing else: {names:?}");
|
||||
assert!(names[0].eq_ignore_ascii_case("Wordcraft Nowhere Face"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_twins_are_listed_but_flagged_compatible() {
|
||||
// A twin by name, installed or not here: "Liberation Sans" stands in for Arial wherever it's
|
||||
// the substitute; a made-up family's substitute is never its twin.
|
||||
let m = missing_in(doc_of(vec![para_in("Text 😀", "Arial"), para_in("Text", "Wordcraft Nowhere Face")]));
|
||||
let nowhere = m.iter().find(|f| f.font == "Wordcraft Nowhere Face").expect("listed");
|
||||
assert!(!nowhere.metric_compatible, "{nowhere:?}");
|
||||
match crate::cmd::font::shown_as(&wordcraft_doc::Document::new(), "Arial") {
|
||||
// Arial is installed: not listed.
|
||||
None => assert!(!m.iter().any(|f| f.font == "Arial"), "{m:?}"),
|
||||
Some(sub) => {
|
||||
let arial = m.iter().find(|f| f.font == "Arial").expect("listed");
|
||||
// An emoji drawn in a fallback face doesn't change the verdict on the substitute.
|
||||
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", &sub), "{arial:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_roman_font_drawn_in_cambria_is_reported() {
|
||||
// Word draws a missing font the font table classes as roman in Cambria (#1613): installed or
|
||||
// not, Cambria isn't the font the document asks for.
|
||||
const ROMAN: &str = "Wordcraft Roman Gone";
|
||||
let mut doc = doc_of(vec![para_in("Text", ROMAN)]);
|
||||
doc.settings.roman_fonts = vec![ROMAN.into()];
|
||||
let m = missing_in(doc.clone());
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [ROMAN], "{m:?}");
|
||||
assert!(!m[0].metric_compatible, "{m:?}");
|
||||
// The font box names the face layout draws, not the generic substitute.
|
||||
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face.get().family.clone();
|
||||
assert_eq!(m[0].shown_as, std::slice::from_ref(&cambria));
|
||||
let mut s = Session::new(doc);
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["fontShownAs"], cambria.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_east_asian_font_is_reported_for_cjk_text_only() {
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some("Wordcraft Nowhere Mincho".into()), ..Default::default() };
|
||||
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc 日本語", props.clone())]));
|
||||
assert_eq!(m.len(), 1, "{m:?}");
|
||||
assert_eq!(m[0].font, "Wordcraft Nowhere Mincho");
|
||||
assert!(!m[0].shown_as.is_empty() && !m[0].metric_compatible, "{m:?}");
|
||||
// Without CJK text the East Asian font draws nothing.
|
||||
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("abc", props)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_fonts_are_shown_as_unicode_without_a_missing_font() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("document.setText", &json!({"text": "\u{F0FC}"})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": "Wingdings"})).unwrap();
|
||||
// Not installed, so it's drawn in another face, but on purpose: nothing to report.
|
||||
if wordcraft_fonts::word::resolve("Wingdings", false, false).substituted {
|
||||
assert!(s.run("format.state", &json!({})).unwrap()["fontShownAs"].is_string());
|
||||
}
|
||||
let layout = s.layout();
|
||||
assert!(crate::cmd::font::missing_fonts(&s.doc, &layout).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fonts_from_command_search_say_so_in_the_status_line() {
|
||||
let mut s = Session::new(doc_of(vec![para_in("Text", "Wordcraft Nowhere Face")]));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.contains("Wordcraft Nowhere Face"), "{}", s.status);
|
||||
// Thousands of missing fonts: a few named, the rest counted.
|
||||
let mut s = Session::new(doc_of((0..40).map(|i| para_in("x", &format!("Wordcraft Many Face {i}"))).collect()));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.ends_with(" and 35 more") && s.status.len() < 300, "{}", s.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_font_name_is_kept_short() {
|
||||
let name = format!("Wordcraft {}", "é".repeat(100_000));
|
||||
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert!(m[0].font.chars().count() <= 256, "{}", m[0].font.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_fonts_drawn_as_unicode_are_not_reported() {
|
||||
// Word's bullets are Symbol characters; WordCraft draws them as their Unicode equivalents.
|
||||
let m = missing_in(doc_of(vec![para_in("\u{F0B7}", "Symbol"), para_in("\u{F0FC}", "Wingdings")]));
|
||||
assert!(m.is_empty(), "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_text_is_reported_under_its_east_asian_font_not_the_latin_one() {
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), font_ea: Some("Wordcraft Asian Gone".into()), ..Default::default() };
|
||||
let names = |text: &str| -> Vec<String> {
|
||||
missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text(text, props.clone())])).into_iter().map(|f| f.font).collect()
|
||||
};
|
||||
assert_eq!(names("日本語"), ["Wordcraft Asian Gone"], "no character asks for the Latin font");
|
||||
assert_eq!(names("abc 日本語"), ["Wordcraft Asian Gone", "Wordcraft Latin Gone"]);
|
||||
// Without an East Asian font the CJK text asks for the Latin one.
|
||||
let latin_only = wordcraft_doc::CharProps { font: Some("Wordcraft Latin Gone".into()), ..Default::default() };
|
||||
let m = missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("日本語", latin_only)]));
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), ["Wordcraft Latin Gone"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_font_named_for_complex_script_text_is_not_reported() {
|
||||
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_cs: Some("Symbol".into()), ..Default::default() };
|
||||
assert!(missing_in(doc_of(vec![wordcraft_doc::Paragraph::with_text("سلام", props)])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_number_of_missing_fonts_stays_bounded() {
|
||||
let paras = (0..3000).map(|i| para_in("x", &format!("Wordcraft Hostile Face {i}"))).collect();
|
||||
let m = missing_in(doc_of(paras));
|
||||
assert!(!m.is_empty() && m.len() <= wordcraft_layout::MAX_SUBSTITUTED, "{}", m.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_key_matches_the_listed_name_however_long_or_padded() {
|
||||
// Leading spaces and more than 256 characters: layout keeps 256, the list trims them.
|
||||
let name = format!(" Wordcraft {}", "x".repeat(400));
|
||||
let m = missing_in(doc_of(vec![para_in("Text", &name)]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(crate::cmd::font::font_key(&name), crate::cmd::font::font_key(&m[0].font));
|
||||
// The status line shortens such names.
|
||||
let mut s = Session::new(doc_of(vec![para_in("Text", &name)]));
|
||||
s.run("font.missing", &json!({})).unwrap();
|
||||
assert!(s.status.chars().count() < 80, "{}", s.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_characters_that_draw_count() {
|
||||
use wordcraft_layout::para::draws_something;
|
||||
for c in ['a', '日', '€', '\u{1F600}'] {
|
||||
assert!(draws_something(c), "{c:?}");
|
||||
}
|
||||
for c in [' ', '\t', '\u{FFFC}', '\u{00AD}', '\u{200B}', '\u{FE0F}', '\u{3164}', '\u{FFF0}', '\u{1BCA0}', '\u{E0041}'] {
|
||||
assert!(!draws_something(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_font_name_is_not_a_missing_font() {
|
||||
// A file's `w:ascii=" "`: the text is drawn in a substitute, but there's no font to name.
|
||||
assert!(missing_in(doc_of(vec![para_in("Text", " "), para_in("More", "\t")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_padded_name_is_judged_by_the_face_its_text_is_drawn_in() {
|
||||
// Layout looks "Arial " up spaces and all, so its text is in a substitute even where Arial is
|
||||
// installed; one of Arial's twins (Helvetica on a Mac) measures like it.
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "Arial ")]));
|
||||
let Some(arial) = m.iter().find(|f| f.font == "Arial") else { return };
|
||||
let drawn = arial.shown_as.first().expect("drawn in a face");
|
||||
assert_eq!(arial.metric_compatible, wordcraft_fonts::word::metric_compatible("Arial", drawn), "{arial:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
// MS Mincho is roman in many font tables, but Cambria is the run's font's substitute: an
|
||||
// East Asian font's text goes to the run's font or a fallback face, as the list says.
|
||||
const MINCHO: &str = "Wordcraft Mincho Gone";
|
||||
let props = wordcraft_doc::CharProps { font: Some("JetBrains Mono".into()), font_ea: Some(MINCHO.into()), ..Default::default() };
|
||||
let mut doc = doc_of(vec![wordcraft_doc::Paragraph::with_text("日", props)]);
|
||||
doc.settings.roman_fonts = vec![MINCHO.into()];
|
||||
let shown = crate::cmd::font::east_asian_shown_as(&doc, MINCHO, "JetBrains Mono").expect("not installed");
|
||||
let m = missing_in(doc.clone());
|
||||
assert_eq!(m.iter().map(|f| f.font.as_str()).collect::<Vec<_>>(), [MINCHO], "{m:?}");
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! results are what Word produces for the same keys.
|
||||
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{BorderStyle, VertAlign};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
@@ -483,7 +484,7 @@ fn autocorrect_replacement_list_follows_the_texts_language() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "document default from the editing language");
|
||||
let mut s = self::s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.lang = None;
|
||||
typ(&mut s, "Ala i kot ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Ala i kot ", "untagged text in the editing language");
|
||||
// `eng` is English too, as for proofing.
|
||||
@@ -833,6 +834,28 @@ fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() {
|
||||
use wordcraft_doc::props::Align;
|
||||
// Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting
|
||||
// change is Ben's and holds Ana's centered paragraph without a border.
|
||||
let mut s = typed("---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert!(p.props.borders.and_then(|b| b.bottom).is_some());
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
|
||||
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
|
||||
assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false)));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
|
||||
let mut s = typed("+--+------+");
|
||||
@@ -1065,7 +1088,7 @@ fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// A list level past the last one, from a style or set directly, goes to the last level.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
let style = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
@@ -1169,6 +1192,10 @@ fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
let s = with_heading_styles("One two three four five\n\n");
|
||||
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
|
||||
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() {
|
||||
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
|
||||
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
|
||||
let s = with_heading_styles(keys);
|
||||
@@ -1186,15 +1213,26 @@ fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
// A list item numbered by the Normal style is not body text either.
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = q.doc.styles.get_mut("Normal") {
|
||||
let num = Arc::make_mut(&mut q.doc.numbering).try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = Arc::make_mut(&mut q.doc.styles).get_mut("Normal") {
|
||||
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
}
|
||||
typ(&mut q, "Item\n\n");
|
||||
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
|
||||
// Off by default.
|
||||
// A line with an outline level stays; the body-text level (9 and above) is no outline level.
|
||||
for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] {
|
||||
let mut q = with_heading_styles("Title\n");
|
||||
q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level);
|
||||
typ(&mut q, "\n");
|
||||
assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_are_off_by_default() {
|
||||
let s = typed("Introduction\n\n");
|
||||
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
|
||||
let h = |t: &str| (t.to_string(), None);
|
||||
assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1211,6 +1249,18 @@ fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
// Enter in the empty paragraph turns a zero-tab line into Heading 1.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
|
||||
|
||||
// With Track Changes on, the tab is marked deleted and the style change is a formatting
|
||||
// revision, so Reject All brings the line back as it was.
|
||||
let mut s = with_heading_styles("\tMethods\n");
|
||||
@@ -1244,6 +1294,64 @@ fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() {
|
||||
use wordcraft_doc::props::Align;
|
||||
// Ana centers a zero-tab line; Ben's Enter makes it Heading 1.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben"));
|
||||
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
|
||||
|
||||
// Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is
|
||||
// removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
s.author = "Ben".into();
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str());
|
||||
assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions);
|
||||
assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center))));
|
||||
// Rejecting it brings back Ana's centered Normal line.
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center)));
|
||||
// The author's own earlier change keeps the formatting before it.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.author = "Ana".into();
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
let ch = p.props.fmt_change.as_deref();
|
||||
assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana"));
|
||||
assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None));
|
||||
assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
let mut s = s();
|
||||
@@ -1255,6 +1363,25 @@ fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() {
|
||||
// Read only, with an editable region from the end of "Title" to the start of "End.": Enter
|
||||
// twice in the region breaks the paragraph and leaves the protected line Normal text, and a
|
||||
// border line typed there goes under the paragraph above it, inside the region.
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd."));
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) };
|
||||
run(&mut s, "review.restrict", json!({"exception": "add"}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
|
||||
run(&mut s, "select.text", json!({"text": "X"}));
|
||||
typ(&mut s, "\n\n");
|
||||
assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s));
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
typ(&mut s, "---\n");
|
||||
assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some());
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_bullets() {
|
||||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||||
@@ -2036,7 +2163,7 @@ fn typing_rtl_letters_into_an_empty_paragraph_makes_it_rtl() {
|
||||
assert_eq!(bidi(&s, 1), Some(true), "Enter carries the paragraph's direction on, as in Word");
|
||||
// A style that sets left-to-right wins.
|
||||
let mut doc = Session::new(wordcraft_doc::Document::new());
|
||||
if let Some(st) = doc.doc.styles.get_mut("Normal") {
|
||||
if let Some(st) = Arc::make_mut(&mut doc.doc.styles).get_mut("Normal") {
|
||||
st.para.bidi = Some(false);
|
||||
}
|
||||
typ(&mut doc, "سلام");
|
||||
@@ -3043,15 +3170,15 @@ fn style_inherited_list(kind: wordcraft_doc::numbering::ListKind, chain: bool) -
|
||||
use wordcraft_doc::props::NumRef;
|
||||
use wordcraft_doc::styles::Style;
|
||||
let mut doc = wordcraft_doc::Document::from_text("Alpha\nBeta");
|
||||
let num = doc.numbering.try_add_list(kind).unwrap();
|
||||
doc.styles.styles.push(Style {
|
||||
let num = Arc::make_mut(&mut doc.numbering).try_add_list(kind).unwrap();
|
||||
Arc::make_mut(&mut doc.styles).styles.push(Style {
|
||||
id: "ListBase".into(),
|
||||
name: "Original List".into(),
|
||||
para: wordcraft_doc::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
if chain {
|
||||
doc.styles.styles.push(Style {
|
||||
Arc::make_mut(&mut doc.styles).styles.push(Style {
|
||||
id: "ListChild".into(),
|
||||
name: "Original Derived".into(),
|
||||
based_on: Some("ListBase".into()),
|
||||
@@ -3127,13 +3254,13 @@ fn list_toggle_uses_effective_level_and_override() {
|
||||
for explicit_kind in [false, true] {
|
||||
let mut s = style_inherited_list(kind, true);
|
||||
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
|
||||
let num = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
|
||||
let num = Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListBase").unwrap().para.numbering.as_mut().unwrap();
|
||||
num.level = 1;
|
||||
let num_id = num.num;
|
||||
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
if override_level {
|
||||
s.doc.numbering.abstracts[0].levels[1] = levels_for(opposite)[1].clone();
|
||||
s.doc.numbering.nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[1] = levels_for(opposite)[1].clone();
|
||||
Arc::make_mut(&mut s.doc.numbering).nums[0].level_overrides.push((1, levels_for(kind)[1].clone()));
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let params = if explicit_kind { json!({"kind": if kind == ListKind::Bullet { "bullet" } else { "numbered" }}) } else { json!({}) };
|
||||
@@ -3162,7 +3289,7 @@ fn list_paragraph_style_survives_inherited_toggle_off() {
|
||||
use wordcraft_doc::styles::Style;
|
||||
for kind in [ListKind::Bullet, ListKind::Numbered] {
|
||||
let mut s = style_inherited_list(kind, false);
|
||||
*s.doc.styles.styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
|
||||
*Arc::make_mut(&mut s.doc.styles).styles.iter_mut().find(|x| x.id == "ListParagraph").unwrap() = Style {
|
||||
id: "ListParagraph".into(),
|
||||
name: "Original formatted list".into(),
|
||||
based_on: Some("ListBase".into()),
|
||||
@@ -3217,7 +3344,7 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
|
||||
for (kind, command) in [(ListKind::Bullet, "para.bullets"), (ListKind::Numbered, "para.numbering")] {
|
||||
let mut s = style_inherited_list(kind, false);
|
||||
let opposite = if kind == ListKind::Bullet { ListKind::Numbered } else { ListKind::Bullet };
|
||||
s.doc.numbering.abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
Arc::make_mut(&mut s.doc.numbering).abstracts[0].levels[0] = levels_for(opposite)[0].clone();
|
||||
let num = s.doc.numbering.nums[0].id;
|
||||
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = Some(NumRef { num, level: 1 });
|
||||
s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props = ParaProps::default();
|
||||
@@ -3273,7 +3400,7 @@ fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
|
||||
@@ -3335,7 +3462,7 @@ fn highlight_toggle_checks_past_the_resolved_run_limit() {
|
||||
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
|
||||
.collect();
|
||||
if inherited {
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Yellow);
|
||||
} else {
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
@@ -3395,7 +3522,7 @@ fn highlight_toggle_checks_whole_cell_marks() {
|
||||
Pos::new(StoryRef::Body, path, 0)
|
||||
};
|
||||
if inherited {
|
||||
s.doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
Arc::make_mut(&mut s.doc.styles).default_chr.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
for row in 0..2 {
|
||||
for col in 0..2 {
|
||||
@@ -3447,7 +3574,7 @@ fn highlight_toggle_checks_ordinary_paragraph_marks() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
for text in ["AB", "AB\n\nAB", "\n"] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(text);
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Blue);
|
||||
Arc::make_mut(&mut doc.styles).default_chr.highlight = Some(Highlight::Blue);
|
||||
let paths = doc.para_paths(StoryRef::Body);
|
||||
for path in &paths {
|
||||
let p = doc.para_mut(StoryRef::Body, path).unwrap();
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct ViewState {
|
||||
pub read_width: f32,
|
||||
#[serde(skip, default = "default_read_height")]
|
||||
pub read_height: f32,
|
||||
/// Reader-only layout controls; they are neither document formatting nor saved UI prefs.
|
||||
/// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs.
|
||||
/// `None` chooses responsive column width and keeps the document's line spacing.
|
||||
#[serde(skip)]
|
||||
pub read_column_width: Option<f32>,
|
||||
@@ -69,7 +69,7 @@ pub struct ViewState {
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on.
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value.
|
||||
#[serde(skip)]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub dark_mode: bool,
|
||||
|
||||
@@ -192,6 +192,41 @@ pub fn charset_base_family(family: &str) -> &str {
|
||||
.map_or(family, str::trim_end)
|
||||
}
|
||||
|
||||
/// Fonts made to measure alike (the same advance widths and line height), so text drawn in one
|
||||
/// wraps and paginates as in the others: Carlito for Calibri, Caladea for Cambria, Liberation and
|
||||
/// Croscore fonts for Arial, Times New Roman and Courier New, the Palatino look-alikes for Palatino
|
||||
/// Linotype and Book Antiqua, and Arial and Helvetica (drawn to Helvetica's widths) for one
|
||||
/// another. The one list of them: a missing font is drawn in its first installed twin
|
||||
/// ([`substitute`]), and text drawn in a twin doesn't change the layout ([`metric_compatible`]).
|
||||
/// Not macOS Times or Courier: their advance widths differ from Times New Roman's and Courier
|
||||
/// New's.
|
||||
const METRIC_TWINS: &[&[&str]] = &[
|
||||
&["Arial", "Liberation Sans", "Helvetica", "Arimo"],
|
||||
&["Times New Roman", "Liberation Serif", "Tinos"],
|
||||
&["Courier New", "Liberation Mono", "Cousine"],
|
||||
&["Calibri", "Carlito"],
|
||||
&["Cambria", "Caladea"],
|
||||
&["Palatino Linotype", "Palatino", "Book Antiqua", "TeX Gyre Pagella", "P052", "URW Palladio L", "Palladio"],
|
||||
];
|
||||
|
||||
/// The fonts that measure like `family` ([`METRIC_TWINS`]), in the order they are tried, without
|
||||
/// `family` itself.
|
||||
fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
let f = family.trim();
|
||||
METRIC_TWINS
|
||||
.iter()
|
||||
.filter(move |g| g.iter().any(|t| t.eq_ignore_ascii_case(f)))
|
||||
.flat_map(|g| g.iter().copied())
|
||||
.filter(move |t| !t.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Whether `substitute` was made to measure like `family` ([`METRIC_TWINS`]), so text drawn in it
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
/// Persian font packs; matched by name only).
|
||||
fn is_persian_naskh(f: &str) -> bool {
|
||||
@@ -343,6 +378,10 @@ fn is_hebrew(f: &str) -> bool {
|
||||
/// letters and digits as well as Thai ones (a Thai face without Latin would leave a mixed run in
|
||||
/// two fonts, #1549), else the first with Thai letters, else the first installed.
|
||||
fn substitute(db: &FontDb, family: &str) -> Option<&'static str> {
|
||||
// A twin draws the text as the font would: Carlito for Calibri, Helvetica for Arial on a Mac.
|
||||
if let Some(twin) = metric_twins(family).find(|t| db.has_family(t)) {
|
||||
return Some(twin);
|
||||
}
|
||||
let mut installed = substitutes(family).iter().copied().filter(|s| db.has_family(s));
|
||||
if !is_thai_family(family) {
|
||||
return installed.next();
|
||||
@@ -477,6 +516,30 @@ mod tests {
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_compatible_substitutes_are_known() {
|
||||
assert!(metric_compatible("Calibri", "Carlito"));
|
||||
assert!(metric_compatible(" arial ", "Liberation Sans"));
|
||||
assert!(metric_compatible("Arial CE", "Helvetica"));
|
||||
assert!(metric_compatible("Book Antiqua", "TeX Gyre Pagella"));
|
||||
assert!(!metric_compatible("Calibri", "Helvetica Neue"));
|
||||
assert!(!metric_compatible("Open Sans", "Carlito"));
|
||||
assert!(!metric_compatible("Times New Roman", "Times"));
|
||||
assert_eq!(metric_twins("arial").collect::<Vec<_>>(), ["Liberation Sans", "Helvetica", "Arimo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_font_is_drawn_in_an_installed_twin() {
|
||||
// The twins that decide the font box's warning are the faces layout draws in.
|
||||
let db = FontDb::global();
|
||||
for family in METRIC_TWINS.iter().flat_map(|g| g.iter()) {
|
||||
let r = resolve(family, false, false);
|
||||
if r.substituted && metric_twins(family).any(|t| db.has_family(t)) {
|
||||
assert!(metric_compatible(family, &r.face.family), "{family} → {}", r.face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_substitutes() {
|
||||
let r = resolve("Definitely Not A Font 123", false, false);
|
||||
|
||||
@@ -56,9 +56,10 @@ pub fn notes(doc: &Document) -> Vec<Note> {
|
||||
let mut open: Vec<u32> = Vec::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (off, c) in p.text.char_indices() {
|
||||
let mut objects = p.objects.iter();
|
||||
for c in p.text.chars() {
|
||||
if c == OBJ {
|
||||
match p.object_at(off) {
|
||||
match objects.next() {
|
||||
Some(InlineObject::CommentStart { id }) => {
|
||||
if seen.insert(*id) {
|
||||
order.push(*id);
|
||||
@@ -198,3 +199,6 @@ fn text(notes: &[Note]) -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::{Block, CharProps, Comment, Paragraph, PartKind, Run, Table, para_block};
|
||||
|
||||
fn paragraph(text: String, objects: Vec<InlineObject>) -> Paragraph {
|
||||
let len = text.len();
|
||||
Paragraph { text, objects, runs: vec![Run { len, props: CharProps::default() }], ..Paragraph::new() }
|
||||
}
|
||||
|
||||
fn comment(doc: &mut Document, id: u32, author: &str, parent: Option<u32>, text: &str) {
|
||||
let part = doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
|
||||
doc.comments.insert(id, Comment { author: author.into(), part, parent, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_walk_preserves_comment_quotes_order_and_unpaired_objects() {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, " Ada ", None, "First note");
|
||||
comment(&mut doc, 3, " ", Some(9), "Reply text");
|
||||
comment(&mut doc, 5, "", None, "Second note");
|
||||
comment(&mut doc, 7, "Grace", None, "Unanchored");
|
||||
comment(&mut doc, 12, "Linus", None, "Cell note");
|
||||
doc.body = vec![para_block(paragraph(
|
||||
format!("é漢{OBJ}α {OBJ}β{OBJ} γ{OBJ}{OBJ}"),
|
||||
vec![
|
||||
InlineObject::CommentStart { id: 9 },
|
||||
InlineObject::CommentStart { id: 5 },
|
||||
InlineObject::CommentEnd { id: 9 },
|
||||
InlineObject::CommentEnd { id: 5 },
|
||||
// No marker for this object: it must not anchor the unanchored comment.
|
||||
InlineObject::BookmarkStart { name: "unrelated".into() },
|
||||
InlineObject::CommentStart { id: 7 },
|
||||
],
|
||||
))];
|
||||
// Missing object storage must not replay an earlier comment marker.
|
||||
doc.body.push(para_block(paragraph(format!("{OBJ}{OBJ}tail"), vec![])));
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
table.rows[0].cells[0].blocks =
|
||||
vec![para_block(paragraph(format!("{OBJ}cell{OBJ}"), vec![InlineObject::CommentStart { id: 12 }, InlineObject::CommentEnd { id: 12 }]))];
|
||||
doc.body.push(std::sync::Arc::new(Block::Table(table)));
|
||||
let before = doc.clone();
|
||||
let actual = notes(&doc);
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![
|
||||
Note { author: "Ada".into(), quote: "α β".into(), text: "First note".into(), replies: vec![("Author".into(), "Reply text".into())] },
|
||||
Note { author: "Author".into(), quote: "β γ".into(), text: "Second note".into(), replies: vec![] },
|
||||
Note { author: "Linus".into(), quote: "cell".into(), text: "Cell note".into(), replies: vec![] },
|
||||
Note { author: "Grace".into(), quote: "".into(), text: "Unanchored".into(), replies: vec![] },
|
||||
]
|
||||
);
|
||||
assert_eq!(doc, before, "export discovery must leave the document unchanged");
|
||||
}
|
||||
|
||||
fn dense_markers(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
comment(&mut doc, 9, "Ada", None, "Kept note");
|
||||
let mut objects = Vec::with_capacity(2 * n + 2);
|
||||
for k in 0..n {
|
||||
objects.push(InlineObject::BookmarkStart { name: format!("bookmark{k}") });
|
||||
objects.push(InlineObject::BookmarkEnd { name: format!("bookmark{k}") });
|
||||
}
|
||||
objects.extend([InlineObject::CommentStart { id: 9 }, InlineObject::CommentEnd { id: 9 }]);
|
||||
doc.body = vec![para_block(paragraph(format!("{}{OBJ}é漢 quote{OBJ}", OBJ.to_string().repeat(2 * n)), objects))];
|
||||
doc
|
||||
}
|
||||
|
||||
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
|
||||
// performance evidence; the semantic assertions below still run on every platform.
|
||||
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
|
||||
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let before = ran();
|
||||
f();
|
||||
match (before, ran()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
|
||||
#[test]
|
||||
fn comment_export_discovery_scales_with_unrelated_markers() {
|
||||
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
|
||||
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
|
||||
assert_eq!(notes(&small), expected);
|
||||
assert_eq!(notes(&big), expected);
|
||||
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
|
||||
for _ in 0..3 {
|
||||
let a = cpu_cost(|| {
|
||||
for _ in 0..8 {
|
||||
std::hint::black_box(notes(&small));
|
||||
}
|
||||
});
|
||||
let b = cpu_cost(|| {
|
||||
std::hint::black_box(notes(&big));
|
||||
});
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
t1 = t1.min(a / 8.0);
|
||||
t8 = t8.min(b);
|
||||
}
|
||||
}
|
||||
if t1.is_finite() && t8.is_finite() {
|
||||
let ratio = t8 / t1;
|
||||
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
|
||||
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
|
||||
} else {
|
||||
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
|
||||
}
|
||||
}
|
||||
@@ -42,5 +42,7 @@ pub use export::{esc, export, export_flow, export_fragment, inlines_html};
|
||||
use import::parse_in;
|
||||
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
|
||||
|
||||
#[cfg(test)]
|
||||
use css::css_unescape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -46,6 +46,48 @@ pub(super) fn strip_important(v: &str) -> &str {
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
|
||||
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
|
||||
/// form feed count as one newline, as CSS reads them (§3.3).
|
||||
pub(super) fn css_unescape(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
let mut it = s.chars().peekable();
|
||||
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
|
||||
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
|
||||
Some('\r') => {
|
||||
it.next();
|
||||
it.next_if_eq(&'\n');
|
||||
}
|
||||
Some(' ' | '\t' | '\n' | '\u{C}') => {
|
||||
it.next();
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
while let Some(c) = it.next() {
|
||||
if c != '\\' {
|
||||
o.push(c);
|
||||
continue;
|
||||
}
|
||||
let mut hex = String::new();
|
||||
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
|
||||
hex.extend(it.next());
|
||||
}
|
||||
if hex.is_empty() {
|
||||
// Before a newline it continues the line; at the end it is dropped.
|
||||
match it.peek() {
|
||||
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
|
||||
Some(_) => o.extend(it.next()),
|
||||
None => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
skip_space(&mut it);
|
||||
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
|
||||
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Apply a `style` attribute to formatting / block properties.
|
||||
pub(super) fn apply_style(
|
||||
css: &str,
|
||||
@@ -88,7 +130,7 @@ pub(super) fn apply_style(
|
||||
}
|
||||
}
|
||||
"font-family" => {
|
||||
let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string();
|
||||
let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
|
||||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
|
||||
@@ -19,6 +19,24 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
|
||||
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
|
||||
/// these are written as hex escapes ([`css_unescape`] reads them back).
|
||||
fn css_font_name(name: &str) -> String {
|
||||
let mut o = String::with_capacity(name.len() + 2);
|
||||
o.push('\'');
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'\0' => o.push('\u{FFFD}'),
|
||||
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
|
||||
c => o.push(c),
|
||||
}
|
||||
}
|
||||
o.push('\'');
|
||||
o
|
||||
}
|
||||
|
||||
fn align_css(a: Option<Align>) -> Option<&'static str> {
|
||||
match a? {
|
||||
Align::Center => Some("text-align:center"),
|
||||
@@ -92,7 +110,7 @@ fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||||
}
|
||||
if let Some(fam) = &f.font {
|
||||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||||
css.push(format!("font-family:{}", esc(&css_font_name(fam))));
|
||||
}
|
||||
if !css.is_empty() {
|
||||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||||
|
||||
@@ -314,6 +314,44 @@ fn leading_space_survives_round_trip() {
|
||||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_escapes_read_every_kind_of_newline() {
|
||||
// A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one
|
||||
// of them (CR LF as one) ends the escape.
|
||||
assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE");
|
||||
assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD");
|
||||
assert_eq!(css_unescape("x\\'y\\"), "x'y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_are_written_as_css_strings() {
|
||||
// A font name could end the CSS string or the declaration and add CSS of its own (#3739).
|
||||
let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"];
|
||||
let mut p = Para::default();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() });
|
||||
}
|
||||
let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }));
|
||||
assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}");
|
||||
let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect();
|
||||
assert_eq!(styles.len(), fonts.len(), "{html}");
|
||||
for s in styles {
|
||||
assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}");
|
||||
}
|
||||
// Read back, each run has its font as it was.
|
||||
let back = parse(&html);
|
||||
let runs: Vec<(String, Option<String>)> = back
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None })
|
||||
.collect();
|
||||
for (i, font) in fonts.iter().enumerate() {
|
||||
assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||||
let props = wordcraft_doc::CharProps::default();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::table::{MAX_COLS, MAX_ROWS};
|
||||
use wordcraft_doc::{Align, Document};
|
||||
|
||||
use crate::html::decode_entity;
|
||||
@@ -21,6 +22,11 @@ mod blocks;
|
||||
mod export;
|
||||
mod inlines;
|
||||
|
||||
/// Table cells a Markdown document makes in all, header rows included (hostile input). A short
|
||||
/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column
|
||||
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
|
||||
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
|
||||
|
||||
pub use blocks::{Refs, import, import_on, parse};
|
||||
pub(crate) use export::escape;
|
||||
pub use export::export;
|
||||
@@ -28,5 +34,7 @@ pub use export::export;
|
||||
use export::{escape_line_start, inlines_md, table_md, write_blocks};
|
||||
pub use inlines::parse_inlines;
|
||||
|
||||
#[cfg(test)]
|
||||
use blocks::split_row;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::inlines::parse_inlines;
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, Para, model};
|
||||
use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model};
|
||||
|
||||
/// Nesting limit for block containers (quotes, list items) and inline links.
|
||||
pub(super) const MAX_NEST: usize = 32;
|
||||
@@ -143,7 +143,8 @@ pub fn parse(text: &str) -> model::Flow {
|
||||
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
|
||||
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
|
||||
let refs = take_definitions(&mut lines);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs);
|
||||
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
|
||||
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
|
||||
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
@@ -271,47 +272,69 @@ fn list_marker(t: &str) -> Option<Marker> {
|
||||
if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None }
|
||||
}
|
||||
|
||||
fn split_row(l: &str) -> Vec<String> {
|
||||
/// A table row without its leading and trailing pipes (an escaped last pipe stays).
|
||||
fn row_body(l: &str) -> &str {
|
||||
let t = l.trim();
|
||||
let t = t.strip_prefix('|').unwrap_or(t);
|
||||
let t = if let Some(before) = t.strip_suffix('|') {
|
||||
if let Some(before) = t.strip_suffix('|') {
|
||||
let num_bs = before.chars().rev().take_while(|&c| c == '\\').count();
|
||||
if num_bs % 2 == 0 { before } else { t }
|
||||
} else {
|
||||
t
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted.
|
||||
pub(super) fn split_row(l: &str, keep: usize) -> (Vec<String>, usize) {
|
||||
let mut cells = Vec::new();
|
||||
let mut count = 0usize;
|
||||
let mut cur = String::new();
|
||||
let mut chars = t.chars().peekable();
|
||||
let mut chars = row_body(l).chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
let full = cells.len() >= keep;
|
||||
match c {
|
||||
'\\' => match chars.peek() {
|
||||
Some(&'|') => {
|
||||
cur.push('|');
|
||||
if !full {
|
||||
cur.push('|');
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
Some(&'\\') => {
|
||||
cur.push('\\');
|
||||
cur.push('\\');
|
||||
if !full {
|
||||
cur.push_str("\\\\");
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
_ => cur.push('\\'),
|
||||
_ if !full => cur.push('\\'),
|
||||
_ => {}
|
||||
},
|
||||
'|' => cells.push(std::mem::take(&mut cur).trim().to_string()),
|
||||
_ => cur.push(c),
|
||||
'|' => {
|
||||
if !full {
|
||||
cells.push(std::mem::take(&mut cur).trim().to_string());
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
_ if !full => cur.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
cells.push(cur.trim().to_string());
|
||||
cells
|
||||
if cells.len() < keep {
|
||||
cells.push(cur.trim().to_string());
|
||||
}
|
||||
(cells, count + 1)
|
||||
}
|
||||
|
||||
fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
|
||||
/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every
|
||||
/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row.
|
||||
fn delimiter_row(l: &str, keep: usize) -> Option<(Vec<Option<Align>>, usize)> {
|
||||
if !l.contains('-') {
|
||||
return None;
|
||||
}
|
||||
let cells = split_row(l);
|
||||
let mut out = Vec::new();
|
||||
for c in cells {
|
||||
let mut count = 0usize;
|
||||
// A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one.
|
||||
for c in row_body(l).split('|') {
|
||||
let c = c.trim();
|
||||
let left = c.starts_with(':');
|
||||
let right = c.ends_with(':');
|
||||
@@ -319,14 +342,17 @@ fn delimiter_row(l: &str) -> Option<Vec<Option<Align>>> {
|
||||
if core.is_empty() || !core.chars().all(|x| x == '-') {
|
||||
return None;
|
||||
}
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
if out.len() < keep {
|
||||
out.push(match (left, right) {
|
||||
(true, true) => Some(Align::Center),
|
||||
(false, true) => Some(Align::Right),
|
||||
(true, false) => Some(Align::Left),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Some(out)
|
||||
Some((out, count))
|
||||
}
|
||||
|
||||
/// Does this line start a block that interrupts a paragraph?
|
||||
@@ -386,7 +412,8 @@ fn code_para(line: &str) -> FBlock {
|
||||
}
|
||||
|
||||
/// Parse block structure. `level` is the list nesting level for lists found here.
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<FBlock> {
|
||||
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
|
||||
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
|
||||
let mut out = Vec::new();
|
||||
let mut para: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -460,32 +487,52 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
|
||||
}
|
||||
if t.starts_with('>') {
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
parse_quote(lines, &mut i, depth, level, refs, &mut out);
|
||||
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
|
||||
continue;
|
||||
}
|
||||
if para.is_empty()
|
||||
&& t.contains('|')
|
||||
&& let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l))
|
||||
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
|
||||
{
|
||||
let head = split_row(t);
|
||||
if head.len() == aligns.len() {
|
||||
let (head, head_width) = split_row(t, MAX_COLS);
|
||||
if head_width == width {
|
||||
let mut table = FTable::default();
|
||||
let cell = |s: &str, header: bool, a: Option<Align>| {
|
||||
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
|
||||
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
|
||||
};
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
// The document keeps no more columns or rows than this, nor cells past its budget,
|
||||
// so those aren't built; their lines are still the table's.
|
||||
let cols = aligns.len();
|
||||
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
|
||||
let mut cut = head_width > cols;
|
||||
if fits(0) {
|
||||
budget.set(budget.get() - cols);
|
||||
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 2;
|
||||
while let Some(l) = lines.get(i) {
|
||||
if is_blank(l) || !l.contains('|') || interrupts(l) {
|
||||
break;
|
||||
}
|
||||
let mut cells = split_row(l);
|
||||
cells.resize(aligns.len(), String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
if !table.rows.is_empty() && fits(table.rows.len()) {
|
||||
budget.set(budget.get() - cols);
|
||||
let (mut cells, _) = split_row(l, cols);
|
||||
cells.resize(cols, String::new());
|
||||
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
|
||||
} else {
|
||||
cut = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.push(FBlock::Table(table));
|
||||
if cut {
|
||||
model::dropped(model::Dropped::TableCells);
|
||||
}
|
||||
if !table.rows.is_empty() {
|
||||
out.push(FBlock::Table(table));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -493,7 +540,7 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
|
||||
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
|
||||
{
|
||||
flush_para(&mut para, &mut out, depth, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs);
|
||||
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
|
||||
continue;
|
||||
}
|
||||
para.push(line.clone());
|
||||
@@ -521,7 +568,7 @@ fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
|
||||
out.extend(code.into_iter().map(code_para));
|
||||
}
|
||||
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, out: &mut Vec<FBlock>) {
|
||||
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
|
||||
let mut inner = Vec::new();
|
||||
while let Some(line) = lines.get(*i) {
|
||||
let trimmed = line.trim_start_matches([' ', '\t']);
|
||||
@@ -541,7 +588,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
|
||||
out.push(FBlock::Para(p));
|
||||
return;
|
||||
}
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs) {
|
||||
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
|
||||
if let FBlock::Para(p) = &mut block
|
||||
&& p.kind == Kind::Normal
|
||||
{
|
||||
@@ -579,7 +626,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
|
||||
}
|
||||
|
||||
/// Parse a list starting at `lines[i]`; returns the index after it.
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs) -> usize {
|
||||
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
|
||||
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
|
||||
while let Some(line) = lines.get(i) {
|
||||
let ind = indent(line);
|
||||
@@ -626,7 +673,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
|
||||
p.push_text(&item.join(" "), &Fmt::default());
|
||||
vec![FBlock::Para(p)]
|
||||
} else {
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs)
|
||||
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
|
||||
};
|
||||
let mut placed = false;
|
||||
if inner.is_empty() {
|
||||
|
||||
@@ -356,6 +356,67 @@ fn three_column_table_with_distinctive_text() {
|
||||
assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cells_past_the_document_limits_are_not_built() {
|
||||
// Every body row was padded to the header's width before any cap (#3701).
|
||||
let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&wide);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), 3);
|
||||
assert!(t.rows.iter().all(|r| r.len() == MAX_COLS));
|
||||
assert_eq!(t.rows[2][0].text(), "b");
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the columns left out are logged");
|
||||
// Rows past the limit still belong to the table, so the text after it reads as before.
|
||||
let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&tall);
|
||||
let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") };
|
||||
assert_eq!(t.rows.len(), MAX_ROWS);
|
||||
let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") };
|
||||
assert_eq!(p.text(), "after");
|
||||
assert_eq!(flow.blocks.len(), 2);
|
||||
assert!(crate::model::Dropped::TableCells.reported(), "the rows left out are logged");
|
||||
}
|
||||
|
||||
/// A short row is padded to its header's width, so a small file of one-cell rows under wide
|
||||
/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell
|
||||
/// budget: rows past it are left out and logged, and their lines make no paragraphs.
|
||||
#[test]
|
||||
fn tables_share_one_cell_budget() {
|
||||
let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|"));
|
||||
let fits = crate::markdown::MAX_TABLE_CELLS / MAX_COLS;
|
||||
let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2));
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&src);
|
||||
let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect();
|
||||
let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum();
|
||||
assert!(cells <= crate::markdown::MAX_TABLE_CELLS, "{cells} cells");
|
||||
assert_eq!(tables.iter().map(|t| t.rows.len()).collect::<Vec<_>>(), [fits], "the second table is past the budget");
|
||||
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["after"]);
|
||||
assert!(crate::model::Dropped::TableCells.reported());
|
||||
// Within the budget a table is whole and nothing is logged.
|
||||
crate::model::begin_import();
|
||||
let flow = parse(&format!("{}| 1 | 2 |\n", header(2)));
|
||||
let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) };
|
||||
assert_eq!(t.rows.len(), 2);
|
||||
assert!(!crate::model::Dropped::TableCells.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_table_still_checks_every_header_and_delimiter_cell() {
|
||||
// Only the first 63 cells are kept, but every one is counted and checked.
|
||||
let head = "|a".repeat(100) + "|\n";
|
||||
let delim = |bad: Option<usize>| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::<String>() + "|\n";
|
||||
let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_)));
|
||||
assert!(is_table(format!("{head}{}| b |\n", delim(None))));
|
||||
assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones");
|
||||
assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells");
|
||||
let (cells, n) = split_row(r"| a | b \| c | d |", 2);
|
||||
assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_table_cells_containing_escaped_pipes() {
|
||||
let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n";
|
||||
|
||||
@@ -290,7 +290,7 @@ impl<'a> Builder<'a> {
|
||||
level
|
||||
})
|
||||
.collect();
|
||||
let numbering = &mut self.doc.numbering;
|
||||
let numbering = Arc::make_mut(&mut self.doc.numbering);
|
||||
let aid = numbering.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels, ..Default::default() });
|
||||
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
@@ -425,7 +425,7 @@ pub fn to_doc_in(flow: &Flow, section: wordcraft_doc::SectionProps) -> Document
|
||||
doc.numbering = Default::default();
|
||||
let uses_code = uses_kind(&flow.blocks, Kind::Code, 0);
|
||||
if uses_code {
|
||||
doc.styles.upsert(code_style());
|
||||
Arc::make_mut(&mut doc.styles).upsert(code_style());
|
||||
}
|
||||
let body = Builder::new(&mut doc).blocks(&flow.blocks, 0);
|
||||
doc.body = body;
|
||||
|
||||
@@ -54,6 +54,10 @@ pub(crate) enum Dropped {
|
||||
RunStrings,
|
||||
/// A style name cut to [`MAX_FONT_NAME`].
|
||||
StyleName,
|
||||
/// ODT `text:s` spaces past the document's budget.
|
||||
Spaces,
|
||||
/// Markdown table rows and columns past the table limits or the document's cell budget.
|
||||
TableCells,
|
||||
}
|
||||
|
||||
impl Dropped {
|
||||
@@ -65,8 +69,23 @@ impl Dropped {
|
||||
format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them")
|
||||
}
|
||||
Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"),
|
||||
Dropped::Spaces => {
|
||||
"import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string()
|
||||
}
|
||||
Dropped::TableCells => format!(
|
||||
"import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out",
|
||||
wordcraft_doc::table::MAX_COLS,
|
||||
wordcraft_doc::table::MAX_ROWS,
|
||||
crate::markdown::MAX_TABLE_CELLS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this thread's import has logged this kind ([`dropped`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reported(self) -> bool {
|
||||
REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log that the importer left something of `kind` out: once per kind per imported document
|
||||
|
||||
@@ -43,7 +43,7 @@ use import::Pages;
|
||||
use import::{Pictures, apply_page_layout, read_entry, read_meta};
|
||||
pub use import::{import, parse};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_XML_DEPTH;
|
||||
use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH};
|
||||
pub use package::export;
|
||||
use styles::Styles;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::import::Pages;
|
||||
use super::limits::{MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
|
||||
use super::limits::{MAX_EXTRA_SPACES, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH};
|
||||
use super::styles::Styles;
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model};
|
||||
@@ -116,6 +116,8 @@ pub(super) struct Body<'a> {
|
||||
para: Option<Para>,
|
||||
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
|
||||
xml_space_end: bool,
|
||||
/// What is left of [`MAX_EXTRA_SPACES`].
|
||||
pub(super) spaces_left: usize,
|
||||
para_cont: bool,
|
||||
/// Open lists: (style name, item open, item used).
|
||||
lists: Vec<(Option<String>, bool)>,
|
||||
@@ -169,6 +171,7 @@ impl<'a> Body<'a> {
|
||||
fmt: Vec::new(),
|
||||
para: None,
|
||||
xml_space_end: false,
|
||||
spaces_left: MAX_EXTRA_SPACES,
|
||||
para_cont: false,
|
||||
lists: Vec::new(),
|
||||
lists_saved: Vec::new(),
|
||||
@@ -821,9 +824,16 @@ impl Body<'_> {
|
||||
}
|
||||
self.fmt.push(fmt);
|
||||
}
|
||||
"s" => {
|
||||
// Outside a paragraph `push_text` drops spaces, so they cost no budget.
|
||||
"s" if self.para.is_some() => {
|
||||
let count = get(a, "text:c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1000);
|
||||
self.push_text(&" ".repeat(count));
|
||||
let want = count.saturating_sub(1);
|
||||
let extra = want.min(self.spaces_left);
|
||||
if extra < want {
|
||||
model::dropped(model::Dropped::Spaces);
|
||||
}
|
||||
self.spaces_left -= extra;
|
||||
self.push_text(&" ".repeat(1 + extra));
|
||||
}
|
||||
"tab" => self.push_text("\t"),
|
||||
"line-break" => self.push_text("\n"),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::body::Body;
|
||||
use super::limits::{MAX_ENTRY, MAX_SECTIONS};
|
||||
use super::limits::{MAX_ENTRY, MAX_EXTRA_SPACES, MAX_SECTIONS};
|
||||
use super::styles::{Styles, column_sep, columns_of};
|
||||
use super::xml::{Attrs, Ev, events, get, length, local};
|
||||
use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Option<Vec<u8>> {
|
||||
let f = z.by_name(name).ok()?;
|
||||
@@ -138,8 +139,11 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Opti
|
||||
// A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded.
|
||||
pictures.get(&mut z, path)
|
||||
};
|
||||
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch)).unwrap_or_default();
|
||||
// One `text:s` budget for the headers and footers and then the body.
|
||||
let mut spaces_left = MAX_EXTRA_SPACES;
|
||||
let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, &mut spaces_left)).unwrap_or_default();
|
||||
let mut body = Body::new(&styles, &pages, &mut fetch);
|
||||
body.spaces_left = spaces_left;
|
||||
events(&content, |ev| body.feed(ev));
|
||||
let mut blocks = body.take_blocks();
|
||||
// An open section with nothing in it (after a column section at the end) isn't one: the
|
||||
@@ -164,7 +168,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
let (flow, last, track, font, parts) = parse_tracked(bytes)?;
|
||||
let mut doc = model::to_doc(&flow);
|
||||
if font.is_some() {
|
||||
doc.styles.default_chr.font = font;
|
||||
Arc::make_mut(&mut doc.styles).default_chr.font = font;
|
||||
}
|
||||
doc.last_section = last;
|
||||
page_parts::add(&mut doc, parts);
|
||||
|
||||
@@ -15,3 +15,5 @@ pub(super) const MAX_OPEN_INSERTIONS: usize = 64;
|
||||
|
||||
/// Longest change author or date kept, in characters.
|
||||
pub(super) const MAX_INFO_CHARS: usize = 256;
|
||||
/// Most spaces the document's `text:s` elements add beyond the one each stands for (hostile input).
|
||||
pub(super) const MAX_EXTRA_SPACES: usize = 4 << 20;
|
||||
|
||||
@@ -58,8 +58,9 @@ pub(super) struct Parts {
|
||||
|
||||
/// Read each visible header and footer of the master pages in `pages`, and name it on its master
|
||||
/// page's section as part id 1, 2, … in order, so the sections the body starts carry them.
|
||||
/// [`add`] puts those parts into the document.
|
||||
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>) -> Parts {
|
||||
/// [`add`] puts those parts into the document. `spaces_left` is the document's
|
||||
/// [`MAX_EXTRA_SPACES`] budget, which the body read after them goes on with.
|
||||
pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option<Vec<u8>>, spaces_left: &mut usize) -> Parts {
|
||||
let mut found: Vec<(String, Slot, Vec<Ev>)> = Vec::new();
|
||||
let mut master: Option<String> = None;
|
||||
// The slot being read, its nesting depth and whether it is kept.
|
||||
@@ -109,9 +110,11 @@ pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut d
|
||||
for (name, kind, evs) in found {
|
||||
let mut reader = Body::new(styles, &none, fetch);
|
||||
reader.in_text = true;
|
||||
reader.spaces_left = *spaces_left;
|
||||
for ev in evs {
|
||||
reader.feed(ev);
|
||||
}
|
||||
*spaces_left = reader.spaces_left;
|
||||
out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks()));
|
||||
if let Some(sec) = pages.masters.get_mut(&name) {
|
||||
kind.set(sec, out.parts.len() as u32);
|
||||
|
||||
@@ -488,6 +488,24 @@ fn deeply_nested_header_content_does_not_panic() {
|
||||
assert_eq!(part_text(&d, d.last_section.footers.default), "After");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_footers_share_the_text_s_budget_with_the_body() {
|
||||
// Each header and footer was read with a budget of its own (#3700).
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(3_000);
|
||||
let styles = format!(
|
||||
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>h{run}</text:p></style:header><style:footer><text:p>f{run}</text:p></style:footer></style:master-page></office:master-styles></office:document-styles>"#
|
||||
);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>b{run}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let d = import(&package(&content, Some(&styles))).unwrap();
|
||||
let texts =
|
||||
[part_text(&d, d.last_section.headers.default), part_text(&d, d.last_section.footers.default), d.plain_text(wordcraft_doc::StoryRef::Body)];
|
||||
// One space per element in all three, and one budget of extra spaces between them.
|
||||
assert_eq!(texts.iter().map(|t| t.matches(' ').count()).sum::<usize>(), 3 * 3_000 + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("h ") && texts[1].starts_with("f ") && texts[2].starts_with("b "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_and_column_sections_become_sections() {
|
||||
use wordcraft_doc::section::SectionStart;
|
||||
@@ -1049,6 +1067,38 @@ fn explicit_spaces_at_paragraph_ends_are_kept() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_spaces_have_a_budget_for_the_whole_document() {
|
||||
// 5,000 elements asking for 1,000 spaces each would add 5 million (#3700).
|
||||
let tags = 5_000;
|
||||
let run = r#"<text:s text:c="1000"/>"#.repeat(tags);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text><text:p>a{run}b</text:p><text:p>c<text:s text:c="3"/>d</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
// Once the budget is spent, each element still stands for one space, and the cut is logged.
|
||||
assert_eq!(texts[0].len(), 2 + tags + MAX_EXTRA_SPACES);
|
||||
assert!(texts[0].starts_with("a ") && texts[0].ends_with(" b"));
|
||||
assert_eq!(texts[1], "c d");
|
||||
assert!(crate::model::Dropped::Spaces.reported());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_s_outside_a_paragraph_spends_no_budget() {
|
||||
// Spaces outside a paragraph are dropped, so they mustn't use up the budget (#3700).
|
||||
let stray = r#"<text:s text:c="1000"/>"#.repeat(5_000);
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T}><office:body><office:text>{stray}<text:p>a<text:s text:c="1000"/>b</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
crate::model::begin_import();
|
||||
let f = parse(&package(&content, None)).unwrap();
|
||||
let texts: Vec<String> = paras(&f).iter().map(|p| p.text()).collect();
|
||||
assert_eq!(texts, vec![format!("a{}b", " ".repeat(1000))]);
|
||||
assert!(!crate::model::Dropped::Spaces.reported(), "nothing was cut");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_asian_complex_and_extended_text_properties() {
|
||||
let content = format!(
|
||||
|
||||
@@ -33,6 +33,12 @@ pub fn esc(s: &str) -> String {
|
||||
o
|
||||
}
|
||||
|
||||
/// [`esc`] for a `\fonttbl` name: `;` would end the entry, so it is written `\'3b`, and control
|
||||
/// characters, which `esc` writes as `\line` or `\tab`, are dropped.
|
||||
fn esc_font(name: &str) -> String {
|
||||
esc(&name.replace(char::is_control, "")).replace(';', "\\'3b")
|
||||
}
|
||||
|
||||
pub(super) struct Tables {
|
||||
pub(super) fonts: Vec<String>,
|
||||
pub(super) colors: Vec<Rgb>,
|
||||
@@ -235,7 +241,9 @@ impl Writer {
|
||||
// leaks the reader's NTLM credentials.
|
||||
match f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||||
Some(l) => {
|
||||
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&l.replace('"', "%22")), body));
|
||||
// In a field's quotes `\\` is one backslash; a quote is stored as `%22`.
|
||||
let target = l.replace('\\', "\\\\").replace('"', "%22");
|
||||
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&target), body));
|
||||
}
|
||||
None => out.push_str(&body),
|
||||
}
|
||||
@@ -682,7 +690,7 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl");
|
||||
for (i, f) in w.t.fonts.iter().enumerate() {
|
||||
let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" };
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc(f)));
|
||||
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f)));
|
||||
}
|
||||
out.push_str("}\n{\\colortbl;");
|
||||
for c in &w.t.colors {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::reader::Reader;
|
||||
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
||||
use super::{Cell, Document, FBlock, Flow, Para, model};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Parse RTF bytes into the flow model.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
||||
@@ -305,12 +306,12 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
}
|
||||
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
||||
// wants beyond that it states on each paragraph.
|
||||
let dp = &mut doc.styles.default_para;
|
||||
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
||||
dp.space_before = Some(0.0);
|
||||
dp.space_after = Some(0.0);
|
||||
dp.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
|
||||
if default_font.is_some() {
|
||||
doc.styles.default_chr.font = default_font;
|
||||
Arc::make_mut(&mut doc.styles).default_chr.font = default_font;
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -1229,6 +1229,40 @@ fn a_code_paragraph_round_trips_without_a_direct_code_font() {
|
||||
assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}");
|
||||
}
|
||||
|
||||
/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862).
|
||||
#[test]
|
||||
fn escaped_semicolons_in_a_font_name_stop_at_the_limit() {
|
||||
let mut r = reader::Reader::default();
|
||||
r.st.dest = state::Dest::FontTbl;
|
||||
for _ in 0..100_000 {
|
||||
r.ch(';');
|
||||
}
|
||||
assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1);
|
||||
let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1));
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
assert_eq!(run_fmt(&runs, "x").font, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_semicolon_in_a_font_name_round_trips() {
|
||||
// `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740).
|
||||
let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")];
|
||||
let mut p = Para::default();
|
||||
for (t, font) in fonts {
|
||||
p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() });
|
||||
}
|
||||
let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() };
|
||||
let rtf = export(&model::to_doc(&flow));
|
||||
assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}");
|
||||
let runs = text_runs(rtf.as_bytes());
|
||||
for (t, font) in fonts {
|
||||
assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}");
|
||||
}
|
||||
// A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it.
|
||||
let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}");
|
||||
assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stylesheet_entry_without_a_number_is_normal() {
|
||||
// An entry without `\sN` is style 0; it doesn't replace the entry before it.
|
||||
@@ -1558,8 +1592,42 @@ fn hyperlink_field_targets() {
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
|
||||
assert_eq!(hyperlink(r#"Hyperlink "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/pathlatest".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
|
||||
// A tooltip or frame before the address, a longer field name, escapes and the writer's `%22`.
|
||||
assert_eq!(hyperlink(r#"HYPERLINK \o "tip" \t "_blank" "http://example.com""#), Some("http://example.com".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINKX "http://example.com""#), None);
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "C:\\Docs\\f.rtf""#), Some(r"C:\Docs\f.rtf".into()));
|
||||
assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/a%22b""#), Some(r#"http://example.com/a"b"#.into()));
|
||||
}
|
||||
|
||||
/// A link with a quote in it survives export and import: the writer stores it as `%22`.
|
||||
#[test]
|
||||
fn a_quote_in_a_link_round_trips() {
|
||||
let rtf = br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "http://a.b/x%22y"}}{\fldrslt{link}}}\par}"#;
|
||||
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
|
||||
let d = import(rtf).unwrap();
|
||||
assert_eq!(link(&d).as_deref(), Some(r#"http://a.b/x"y"#));
|
||||
let exported = export(&d);
|
||||
assert!(exported.contains("x%22y"), "{exported}");
|
||||
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(r#"http://a.b/x"y"#));
|
||||
}
|
||||
|
||||
/// Backslashes in a link survive export and import, a trailing one and two in a row too.
|
||||
#[test]
|
||||
fn backslashes_in_a_link_round_trip() {
|
||||
let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone()));
|
||||
for target in [r"docs\", r"a\\b", r"..\up\f.doc"] {
|
||||
let mut d = import(br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "x"}}{\fldrslt{link}}}\par}"#).unwrap();
|
||||
for b in d.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
p.runs.iter_mut().filter(|r| r.props.link.is_some()).for_each(|r| r.props.link = Some(target.into()));
|
||||
}
|
||||
}
|
||||
assert_eq!(link(&d).as_deref(), Some(target));
|
||||
let exported = export(&d);
|
||||
assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(target), "{exported}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding>
|
||||
|
||||
impl Reader {
|
||||
pub(super) fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
let name = std::mem::take(&mut self.font_name).trim().to_string();
|
||||
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
|
||||
&& self.fonts.len() < 4096
|
||||
{
|
||||
@@ -36,6 +36,11 @@ impl Reader {
|
||||
self.skip_chars -= 1;
|
||||
return;
|
||||
}
|
||||
// `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`).
|
||||
if b == b';' && self.st.dest == Dest::FontTbl {
|
||||
self.ch(';');
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
let Some(encoding) = multibyte_encoding(cp) else {
|
||||
self.finish_multibyte();
|
||||
@@ -106,10 +111,11 @@ impl Reader {
|
||||
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
|
||||
/// BOM and the rest decoded as UTF-8.
|
||||
pub(super) fn run(&mut self, chunk: &[u8]) {
|
||||
if self.skip_chars > 0 {
|
||||
for b in chunk {
|
||||
self.byte(*b);
|
||||
}
|
||||
// The fallback bytes after `\uN` are skipped; the rest of the run is text like any other.
|
||||
let skip = self.skip_chars.min(chunk.len());
|
||||
self.skip_chars -= skip;
|
||||
let chunk = chunk.get(skip..).unwrap_or_default();
|
||||
if chunk.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cp = self.code_page();
|
||||
@@ -302,6 +308,14 @@ impl Reader {
|
||||
return;
|
||||
}
|
||||
self.finish_multibyte();
|
||||
// Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it.
|
||||
if c == ';' && self.st.dest == Dest::FontTbl {
|
||||
// Held to the limit as other characters are; one past it drops the name.
|
||||
if self.font_name.len() <= model::MAX_FONT_NAME {
|
||||
self.font_name.push(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
self.text(c.encode_utf8(&mut buf));
|
||||
}
|
||||
|
||||
@@ -507,76 +507,7 @@ impl Reader {
|
||||
}
|
||||
}
|
||||
|
||||
/// The target of a `HYPERLINK "url"` (or `HYPERLINK "url" \l "bookmark"`, `HYPERLINK \l "bookmark"`) field instruction.
|
||||
/// The target of a `HYPERLINK` field instruction; `%22` is a quote, as the writer stores one.
|
||||
pub(super) fn hyperlink(instr: &str) -> Option<String> {
|
||||
let t = instr.trim();
|
||||
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
|
||||
t[9..].trim()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url: Option<String> = None;
|
||||
let mut local: Option<String> = None;
|
||||
let mut switch: Option<&str> = None;
|
||||
|
||||
let mut i = 0;
|
||||
let bytes = rest.as_bytes();
|
||||
while i < bytes.len() {
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[i] == b'\\' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
|
||||
i += 1;
|
||||
}
|
||||
switch = Some(&rest[start..i]);
|
||||
continue;
|
||||
}
|
||||
let arg = if bytes[i] == b'"' {
|
||||
i += 1;
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'"' {
|
||||
i += 1;
|
||||
}
|
||||
let val = &rest[start..i];
|
||||
if i < bytes.len() && bytes[i] == b'"' {
|
||||
i += 1;
|
||||
}
|
||||
val.to_string()
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
rest[start..i].to_string()
|
||||
};
|
||||
let clean_arg = arg.replace("%22", "\"");
|
||||
if switch.take() == Some("\\l") {
|
||||
local = Some(clean_arg);
|
||||
} else if url.is_none() {
|
||||
url = Some(clean_arg);
|
||||
}
|
||||
}
|
||||
|
||||
match (url, local) {
|
||||
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
|
||||
}
|
||||
(Some(u), _) if !u.is_empty() => Some(u),
|
||||
(None, Some(l)) if !l.is_empty() => {
|
||||
let frag = l.trim_start_matches('#');
|
||||
Some(format!("#{frag}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
wordcraft_doc::link::hyperlink_target(instr).map(|t| t.replace("%22", "\""))
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ fn sample() -> Document {
|
||||
p.insert_text(n, ".", &CharProps::default()).unwrap();
|
||||
blocks.push(para_block(p));
|
||||
blocks.push(para_block(Paragraph::with_text("Second Level", CharProps::default()).styled("Heading2")));
|
||||
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
for (t, nm, lvl) in [("first bullet", bul, 0u8), ("second bullet", bul, 0), ("nested bullet", bul, 1)] {
|
||||
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
|
||||
q.props.numbering = Some(wordcraft_doc::props::NumRef { num: nm, level: lvl });
|
||||
@@ -166,8 +166,8 @@ fn check_round_trip(ext: &str, rich: bool) {
|
||||
/// Two bullets directly followed by two numbered items.
|
||||
fn bullets_then_numbers() -> Document {
|
||||
let mut d = Document::new();
|
||||
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let num = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
|
||||
let mut blocks = Vec::new();
|
||||
for (t, nm) in [("dot one", bul), ("dot two", bul), ("num one", num), ("num two", num)] {
|
||||
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
|
||||
@@ -198,7 +198,7 @@ fn numbered_list_after_bullets_keeps_its_kind() {
|
||||
fn markdown_list_items_that_start_like_blocks_keep_their_text() {
|
||||
for text in ["---", "--", "1. Intro", "# Agenda", "ordinary item"] {
|
||||
let mut d = Document::new();
|
||||
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let mut p = Paragraph::with_text(text, CharProps::default()).styled("ListParagraph");
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
|
||||
d.body = vec![para_block(p)];
|
||||
@@ -365,7 +365,7 @@ fn txt_export_writes_symbol_font_text_as_unicode() {
|
||||
#[test]
|
||||
fn txt_export_keeps_list_labels() {
|
||||
let mut d = Document::new();
|
||||
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let bul = Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Bullet);
|
||||
let mut p = Paragraph::with_text("alpha", CharProps::default());
|
||||
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
|
||||
d.body = vec![para_block(p), para_block(Paragraph::with_text("plain", CharProps::default()))];
|
||||
|
||||
@@ -44,6 +44,9 @@ const MAX_PARENT_CHAIN: usize = 64;
|
||||
/// Path given to the synthetic author-name paragraphs: no paragraph of the comment's story has it.
|
||||
const HEADER_PATH: u32 = u32::MAX;
|
||||
|
||||
#[cfg(test)]
|
||||
mod anchor_tests;
|
||||
|
||||
/// One comment's balloon before it is positioned: items relative to the card's top-left.
|
||||
struct Card {
|
||||
page: usize,
|
||||
@@ -74,8 +77,8 @@ pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
|
||||
let mut out = Vec::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
let Some(InlineObject::CommentStart { id }) = p.object_at(off) else { continue };
|
||||
for (off, object) in p.objects_with_offsets() {
|
||||
let InlineObject::CommentStart { id } = object else { continue };
|
||||
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::*;
|
||||
use wordcraft_doc::{Block, Comment, Path, Table, para_block};
|
||||
|
||||
fn push(p: &mut Paragraph, object: InlineObject) {
|
||||
p.insert_object(p.len(), object, &CharProps::default()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_keep_first_root_occurrences_and_utf8_table_positions() {
|
||||
let mut doc = Document::from_text("Préface Ω\nSecond");
|
||||
for (id, parent) in [(40, None), (7, Some(40)), (9, None), (2, None), (3, None)] {
|
||||
doc.comments.insert(id, Comment { parent, ..Default::default() });
|
||||
}
|
||||
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
push(p, InlineObject::BookmarkStart { name: "unrelated".into() });
|
||||
push(p, InlineObject::CommentStart { id: 999 }); // no such comment
|
||||
push(p, InlineObject::CommentStart { id: 7 }); // reply, not its own balloon
|
||||
push(p, InlineObject::CommentEnd { id: 9 });
|
||||
let first = p.len();
|
||||
push(p, InlineObject::CommentStart { id: 40 });
|
||||
push(p, InlineObject::CommentStart { id: 40 }); // first occurrence wins
|
||||
let second = p.len();
|
||||
push(p, InlineObject::CommentStart { id: 9 });
|
||||
let mut table = Table::new(1, 1, 200.0);
|
||||
let mut cell = Paragraph::with_text("Cell λ", CharProps::default());
|
||||
let in_cell = cell.len();
|
||||
push(&mut cell, InlineObject::CommentStart { id: 2 });
|
||||
table.rows[0].cells[0].blocks = vec![para_block(cell)];
|
||||
doc.body.push(std::sync::Arc::new(Block::Table(table)));
|
||||
// Comments in other stories do not acquire body balloons.
|
||||
let mut note = Paragraph::with_text("Note", CharProps::default());
|
||||
push(&mut note, InlineObject::CommentStart { id: 3 });
|
||||
doc.add_part(wordcraft_doc::PartKind::Footnote, vec![para_block(note)]);
|
||||
assert_eq!(
|
||||
anchors(&doc),
|
||||
vec![(40, Pos::body(0, first)), (9, Pos::body(0, second)), (2, Pos::new(StoryRef::Body, Path(vec![2, 0, 0, 0]), in_cell))]
|
||||
);
|
||||
}
|
||||
|
||||
fn dense_markers(n: usize) -> Document {
|
||||
let mut doc = Document::new();
|
||||
doc.comments.insert(0, Comment::default());
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.text = wordcraft_doc::para::OBJ.to_string().repeat(n);
|
||||
p.runs[0].len = p.len();
|
||||
p.objects = vec![InlineObject::BookmarkEnd { name: "unrelated".into() }; n];
|
||||
push(&mut p, InlineObject::CommentStart { id: 0 });
|
||||
doc.body = vec![para_block(p)];
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_walk_dense_unrelated_markers_in_linear_work() {
|
||||
const N: usize = 2_000;
|
||||
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
|
||||
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
|
||||
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
|
||||
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
|
||||
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_keep_the_existing_limit_in_document_order() {
|
||||
let mut doc = Document::new();
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.text = wordcraft_doc::para::OBJ.to_string().repeat(MAX_BALLOONS + 1);
|
||||
p.runs[0].len = p.len();
|
||||
p.objects = (0..=MAX_BALLOONS).map(|id| InlineObject::CommentStart { id: id as u32 }).collect();
|
||||
doc.comments = (0..=MAX_BALLOONS).map(|id| (id as u32, Comment::default())).collect();
|
||||
doc.body = vec![para_block(p)];
|
||||
let got = anchors(&doc);
|
||||
assert_eq!(got.len(), MAX_BALLOONS);
|
||||
assert_eq!(got.first(), Some(&(0, Pos::body(0, 0))));
|
||||
assert_eq!(got.last(), Some(&((MAX_BALLOONS - 1) as u32, Pos::body(0, (MAX_BALLOONS - 1) * wordcraft_doc::para::OBJ.len_utf8()))));
|
||||
}
|
||||
+17
-10
@@ -421,19 +421,20 @@ impl DocLayout {
|
||||
}
|
||||
|
||||
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
|
||||
/// headers/footers). A text box wins inside its area, except one behind the text, which only
|
||||
/// wins where no body text is.
|
||||
/// headers/footers). A text box wins inside its area (its rotated area when it is turned, as
|
||||
/// [`DocLayout::object_at`] takes it), except one behind the text, which only wins where no
|
||||
/// body text is.
|
||||
pub fn story_at(&self, page: usize, x: f32, y: f32) -> Option<StoryRef> {
|
||||
let p = self.pages.get(page)?;
|
||||
let text_box = |behind: bool| {
|
||||
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(Point::new(x, y))).and_then(|o| o.text_box)
|
||||
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(o.unspin(x, y))).and_then(|o| o.text_box)
|
||||
};
|
||||
text_box(false).map(StoryRef::Part).or_else(|| text_at(p, page, x, y)).or_else(|| text_box(true).map(StoryRef::Part))
|
||||
}
|
||||
|
||||
/// The object a press at (x, y) grabs: a picture or shape anywhere on it, a text box on its
|
||||
/// border (within `edge` points either side; inside is its text). Topmost first; objects
|
||||
/// behind the text only where there's no text.
|
||||
/// border (within `edge` points either side; inside is its text, whatever lies under the
|
||||
/// box). Topmost first; objects behind the text only where there's no text.
|
||||
pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
|
||||
self.object_at_in(page, x, y, edge, StoryRef::Body)
|
||||
}
|
||||
@@ -456,11 +457,17 @@ impl DocLayout {
|
||||
StoryRef::Body => text_at(p, page, x, y).is_some(),
|
||||
StoryRef::Part(id) => hf_text_at(p, id, x, y),
|
||||
};
|
||||
cands
|
||||
.iter()
|
||||
.find(|o| !o.behind() && grabs(o))
|
||||
.or_else(|| if text_here { None } else { cands.iter().find(|o| o.behind() && grabs(o)) })
|
||||
.cloned()
|
||||
// Inside a text box is its text: a press there grabs nothing, not an object under the box
|
||||
// (a form scanned as a picture, with text boxes laid over it to fill in). The same holds
|
||||
// among the objects behind the text.
|
||||
let inside_text_box = |o: &ObjectHit| o.text_box.is_some() && !grabs(o) && o.rect.contains(o.unspin(x, y));
|
||||
let topmost = |behind: bool| cands.iter().find(|o| o.behind() == behind && (grabs(o) || inside_text_box(o)));
|
||||
match topmost(false) {
|
||||
Some(o) if inside_text_box(o) => None,
|
||||
Some(o) => Some(o.clone()),
|
||||
None if text_here => None,
|
||||
None => topmost(true).filter(|o| !inside_text_box(o)).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text box in the header or footer at (x, y) on `page`.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied
|
||||
//! on top of the Unicode line-break opportunities (UAX #14).
|
||||
//!
|
||||
//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…)
|
||||
//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these
|
||||
//! breaks; the lists make sure of it for the standard Japanese/Chinese sets.
|
||||
//! - Kinsoku on (the default): no ordinary break opportunity falls before closing East Asian
|
||||
//! punctuation (、。,)」…), a small kana (っゃゕヶ), the prolonged sound mark (ー ー) or an
|
||||
//! iteration mark, and none falls after opening punctuation ((「【…). UAX #14 already forbids
|
||||
//! most of these breaks, and classes the small kana and the prolonged sound mark CJ/NS, whose
|
||||
//! break is only a tailoring choice; the lists make sure of it for the standard Japanese/Chinese
|
||||
//! sets. A line too narrow for any opportunity still takes the emergency break in
|
||||
//! `para::break_lines`, which can put one of these characters at the start of the line.
|
||||
//! - Kinsoku off: a line may break between any two East Asian characters, punctuation included.
|
||||
//! - Word wrap off: Latin words may break at any character.
|
||||
//! - Automatic spacing (`autoSpaceDE`, `autoSpaceDN`): which neighbouring characters get room
|
||||
@@ -14,10 +18,15 @@
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// East Asian characters a line may not start with: closing brackets and quotes, the
|
||||
/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot,
|
||||
/// iteration and sound marks.
|
||||
const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢";
|
||||
/// 行頭禁則: East Asian characters a line may not start with, grouped as JIS X 4051 §4 and
|
||||
/// Word's default 禁則処理 do:
|
||||
/// - 小書きの仮名 (small kana): hiragana `ぁぃぅぇぉっゃゅょゎゕゖ`, katakana `ァィゥェォッャュョヮヵヶ`,
|
||||
/// the katakana phonetic extensions (U+31F0…U+31FF) and the halfwidth small kana `ァィゥェォャュョッ`;
|
||||
/// - 長音記号 (the prolonged sound mark): `ー` and its halfwidth form `ー`;
|
||||
/// - 繰り返し記号 (iteration marks, with the sound marks): `゛゜ヽヾゝゞ々〻゙゚`;
|
||||
/// - 閉じ括弧類 and the rest: closing brackets and quotes, the ideographic comma and full stop,
|
||||
/// the fullwidth and halfwidth punctuation, the middle dot, `%` and `¢`.
|
||||
const NO_START: &str = "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー゛゜ヽヾゝゞ々〻゙゚、。,.・:;?!〉》」』】〕〗〙〛〞〟)]}⦆。」、・%¢";
|
||||
|
||||
/// East Asian characters a line may not end with: opening brackets and quotes, currency signs.
|
||||
const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥";
|
||||
@@ -149,4 +158,36 @@ mod tests {
|
||||
let any = breaks("Hello café", true, false);
|
||||
assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinsoku_no_line_starts_with_a_small_kana_or_the_prolonged_sound_mark() {
|
||||
// A break opportunity sits right before each of these (after a kanji, or after a 、);
|
||||
// JIS X 4051 §4 行頭禁則 drops it, as Word's default 禁則処理 does.
|
||||
let ja = "漢っ、ゃ漢ー漢ッ漢ャ漢ゕ漢ヶ漢ㇺ漢ッ漢ャ漢ー";
|
||||
let non_starters = ["っ", "ゃ", "ー", "ッ", "ャ", "ゕ", "ヶ", "ㇺ", "ッ", "ャ", "ー"];
|
||||
let on = breaks(ja, true, true);
|
||||
for c in non_starters {
|
||||
assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}");
|
||||
}
|
||||
// Kinsoku off: a line may break between any two East Asian characters, these included.
|
||||
let off = breaks(ja, false, true);
|
||||
for c in non_starters {
|
||||
assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}");
|
||||
}
|
||||
// Text with no East Asian characters breaks exactly as before either way.
|
||||
let en = "Hello wonderful (world), 50%.";
|
||||
assert_eq!(breaks(en, true, true), breaks(en, false, true));
|
||||
assert_eq!(breaks(en, true, true), ["w", "(", "5"]);
|
||||
}
|
||||
|
||||
/// 行頭禁則: the small kana and the prolonged sound mark are CJ/NS in UAX #14, where the break
|
||||
/// before them is forbidden only optionally — so the list itself must hold every one of them.
|
||||
#[test]
|
||||
fn no_start_holds_every_small_kana_and_the_prolonged_sound_mark() {
|
||||
for c in "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー".chars()
|
||||
{
|
||||
assert!(super::is_east_asian(c), "U+{:04X} {c} is East Asian text", c as u32);
|
||||
assert!(super::NO_START.contains(c), "NO_START (行頭禁則) must hold U+{:04X} {c}", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ mod notes;
|
||||
mod page;
|
||||
mod pass;
|
||||
mod placed;
|
||||
mod substituted;
|
||||
mod table_flow;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -78,6 +79,7 @@ use page::item_bottom;
|
||||
use pass::layout_pass;
|
||||
pub use pass::now_ms;
|
||||
pub use placed::{HfSlot, Placed, turn_point, turn_rect, unturn_point};
|
||||
pub use substituted::MAX_SUBSTITUTED;
|
||||
|
||||
/// How the document is viewed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) use builder::left_out;
|
||||
#[cfg(test)]
|
||||
use chars::{MISSING_ROMAN_FONT, lookup_family};
|
||||
pub(crate) use chars::{caps_char, push_caps};
|
||||
pub use chars::{draws_something, east_asian_substitute_face, reported_font_name, substitute_face};
|
||||
#[cfg(test)]
|
||||
pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
|
||||
pub use grid::{Exclusion, Grid, is_east_asian};
|
||||
@@ -47,6 +48,9 @@ pub use proof::{ProofRun, is_list_item, paragraph_issues, proof_runs, proof_text
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParaLayout {
|
||||
pub rp: ResolvedPara,
|
||||
/// Fonts the shown text (and list label) asks for that aren't installed, each with a face
|
||||
/// it was drawn in instead: a substitute or a fallback face covering its characters.
|
||||
pub substituted: Vec<(String, FaceRef)>,
|
||||
pub styles: Vec<StyleRun>,
|
||||
pub glyphs: Vec<Glyph>,
|
||||
pub clusters: Vec<Cluster>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user