Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor edfea68101 Release 0.15.2 2026-11-04 19:49:42 -05:00
Dylan M. Taylor 74d3701c74 Release 0.15.2 2026-11-04 19:46:26 -05:00
Dylan M. Taylor 8512ac6f36 Revert the release environment on the workflow call 2026-11-04 19:39:27 -05:00
Dylan M. Taylor 421700e890 Run the signed release from the release environment 2026-11-04 19:36:14 -05:00
Dylan M. Taylor d69cbec87f Release 0.15.2 2026-11-04 18:49:09 -05:00
Dylan M. Taylor 08afab50b6 Merge pull request #5524 from storytold/rel-0.15.1
Release: WordCraft v0.15.1
2026-11-03 22:15:22 -05:00
Dylan M. Taylor cd6b497c8a Release: WordCraft v0.15.1
Bump the workspace version for the point release carrying the CP4.3
proofing work: one language list, the missing-pack bar on a fresh
install, and the Options › Language pane.
2026-11-03 21:59:44 -05:00
Dylan M. Taylor 1340705fa6 Merge pull request #5515 from storytold/fix/options-language
CP4.3: Options › Language: Your languages with proofing status, Add a language…, Set as default
2026-11-03 21:59:26 -05:00
Dylan M. Taylor 98da959ceb Merge pull request #5514 from storytold/fix/missing-pack-bar
CP4.3: the missing-pack bar works on a fresh install and counts the caret's language
2026-11-03 21:54:20 -05:00
391 changed files with 39485 additions and 3418 deletions
+1
View File
@@ -46,3 +46,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+28 -19
View File
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"arboard",
"chrono",
@@ -6820,7 +6820,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"serde_json",
"sys-locale",
@@ -6833,11 +6833,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.0"
version = "0.15.2"
[[package]]
name = "wordcraft-doc"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"base64 0.23.1",
"log",
@@ -6851,7 +6851,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"cfb",
"chrono",
@@ -6868,7 +6868,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6892,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"cfb",
"chrono",
@@ -6925,7 +6925,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6939,26 +6939,34 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"aes",
"base64 0.23.1",
"cfb",
"chardetng",
"chrono",
"encoding_rs",
"getrandom 0.3.4",
"hmac",
"image",
"js-sys",
"log",
"miniz_oxide 0.9.1",
"proptest",
"quick-xml 0.41.0",
"sha1 0.11.0",
"sha2 0.11.1",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zeroize",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"image",
"serde",
@@ -6966,7 +6974,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"craft-proof",
"image",
@@ -6986,7 +6994,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"log",
"serde_json",
@@ -6998,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7006,7 +7014,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"chrono",
"craft-pdf",
@@ -7027,7 +7035,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"image",
"log",
@@ -7041,8 +7049,9 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"accesskit_consumer",
"cfb",
"chrono",
"craft-i18n",
@@ -7073,7 +7082,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"eframe",
"egui",
@@ -7092,7 +7101,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"log",
"proptest",
@@ -7255,7 +7264,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.15.2"
dependencies = [
"serde_json",
]
+3 -1
View File
@@ -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.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -92,6 +92,8 @@ zeroize = "1"
# dependency of other crates.
libc = "0.2"
# time
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
+80 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -10,7 +10,7 @@ use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "ott", "fodt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
@@ -655,6 +655,8 @@ fn mime_for(name: &str) -> &'static str {
Some("pdf") => "application/pdf",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("odt") => "application/vnd.oasis.opendocument.text",
Some("ott") => "application/vnd.oasis.opendocument.text-template",
Some("fodt") => "application/vnd.oasis.opendocument.text-flat-xml",
Some("rtf") => "application/rtf",
Some("html") => "text/html",
Some("md" | "txt") => "text/plain",
+2
View File
@@ -239,6 +239,8 @@ fn format_name(ext: &str) -> String {
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"ott" => "OpenDocument Text Template".into(),
"fodt" => "Flat OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
+39 -2
View File
@@ -27,7 +27,7 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
@@ -54,7 +54,13 @@ impl eframe::App for App {
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
let mut window = self.4;
// Only until the off-screen check below has run: it is the one reader.
if self.1.is_some() {
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
window.on_a_monitor = on_a_monitor;
window.primary_monitor_rect = primary_monitor_rect;
}
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
@@ -126,6 +132,37 @@ impl eframe::App for App {
}
}
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
/// report the window or primary monitor (Wayland, headless).
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
let Some(w) = frame.winit_window() else {
return (None, None);
};
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return (None, None);
}
let Some(pos) = w.outer_position().ok() else {
return (None, None);
};
let size = w.outer_size();
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
};
let monitors: Vec<egui::Rect> =
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
let primary_monitor_rect = w.primary_monitor().map(|m| {
let pos = m.position();
let size = m.size();
egui::Rect::from_min_size(
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
)
});
(on_a_monitor, primary_monitor_rect)
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
+3
View File
@@ -187,3 +187,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
+1
View File
@@ -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};
+3 -2
View File
@@ -10,8 +10,9 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
test-support = []
[dependencies]
+6 -1
View File
@@ -213,6 +213,12 @@ pub struct ShapeExtra {
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
/// it). Dropped when the shape is made another kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf_geometry: Option<std::sync::Arc<String>>,
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -384,7 +390,6 @@ pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32
(0.0, 1.0)
}
}
ShapeKind::Line => (w, -h),
_ => (w, h),
};
let len = (dx * dx + dy * dy).sqrt();
+12
View File
@@ -254,6 +254,18 @@ pub struct ContentControl {
/// `w:sdtEndPr`, kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub end_pr_xml: String,
/// The size of the form control it came from (an ODF `draw:control`'s `svg:width` and
/// `svg:height`), points: kept for saving back as one.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_size: Option<[f32; 2]>,
/// How far that form control stands raised from the baseline (points, negative lowered:
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_raise: Option<f32>,
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
#[serde(skip_serializing_if = "String::is_empty")]
pub form_xml: String,
}
impl ContentControl {
+52 -3
View File
@@ -7,7 +7,7 @@ use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, 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(),
@@ -76,6 +76,7 @@ impl Document {
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
}
}
@@ -152,6 +153,7 @@ impl Document {
passthrough,
glossary,
media_index: _,
kept: _,
} = self;
let Document {
body: other_body,
@@ -171,6 +173,7 @@ impl Document {
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
kept: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
@@ -734,6 +737,52 @@ impl Document {
}
}
/// [`Document::objects_mut`] for filling in what an object shows from what it is (a chart's
/// drawing from its data), which doesn't change the document: the markup kept as read for
/// the paragraphs and tables holding them (see [`crate::kept`]) follows them.
pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
// The stories (text boxes', notes') as read before, when filling changes one: they stay
// as read after it, so the paragraphs showing them keep their markup.
let holds = |b: &Arc<Block>| {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
any
};
let as_read: Vec<u32> = match self.kept.get() {
Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => {
k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect()
}
_ => Vec::new(),
};
let mut moves: Vec<crate::kept::Moved> = Vec::new();
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
if !any {
continue;
}
let before = crate::kept::identities(b);
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
if p.objects.iter().any(wants) {
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
p.touch();
}
});
moves.push((before, crate::kept::identities(b)));
}
}
let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect();
if let Some(k) = self.kept.get_mut() {
for (before, after) in moves {
k.follow(&before, &after);
}
k.story_fps.extend(refreshed);
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
+4 -4
View File
@@ -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);
+21 -19
View File
@@ -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}");
}
+11
View File
@@ -51,6 +51,17 @@ pub struct Embedded {
pub refs: Vec<EmbeddedRef>,
}
impl Embedded {
/// The root element of an ODF embedded object's markup: its files are kept in
/// [`crate::Document::passthrough`] for the ODT writer; DOCX shows it as its picture.
pub const ODF_OBJECT: &'static str = "<wordcraft:odf-object";
/// Is it an ODF embedded object (a spreadsheet in an ODT), not Word markup?
pub fn is_odf_object(&self) -> bool {
self.xml.trim_start().starts_with(Self::ODF_OBJECT)
}
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
+780
View File
@@ -0,0 +1,780 @@
//! Markup kept from the file a document was opened from, so that what isn't edited is written
//! back as it was: a paragraph keeps its source XML, which a writer uses as long as the
//! paragraph still is what the reader made of it (its [`fingerprint`] unchanged). Anything the
//! model has no place for (an unknown element, an attribute nobody reads) survives a save that
//! way. Never saved with the document (WordCraft's own format), and not part of its content
//! (`==` ignores it).
use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use crate::Paragraph;
/// Most kept markup per document, bytes (a hostile file can't make the kept copy grow without
/// bound: past it, paragraphs are written from the model as before).
pub const MAX_KEPT_BYTES: usize = 64 * 1024 * 1024;
/// The kept markup of a document (see the module docs).
#[derive(Clone, Debug, Default)]
pub struct Kept(Option<Arc<KeptXml>>);
/// What a reader kept.
#[derive(Clone, Debug, Default)]
pub struct KeptXml {
/// The format it came from (`"odt"`): only that format's writer uses it.
pub format: String,
/// Paragraph [`fingerprint`] → the paragraph's markup, in the order read (identical
/// paragraphs keep the first's).
pub paras: HashMap<u64, Arc<str>>,
/// The same for the body's tables ([`table_fingerprint`]).
pub tables: HashMap<u64, Arc<str>>,
/// While reading: the markup of the body's `n`th table, until [`Kept::seal`].
pub pending_tables: Vec<(usize, Arc<str>)>,
/// Wrappers kept as read around paragraphs (sections): for a paragraph's fingerprint, the
/// start tags opening before it and how many close after it. Only paragraphs that were the
/// only one of their kind have them (a copy can't open a section twice).
pub wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: the same by paragraph `rev`, until [`Kept::seal`].
pub pending_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// The same for paragraphs with twins (an empty paragraph has many): by fingerprint and
/// [`Paragraph::rev`], which stays while the paragraph does (a writer opens each once).
pub twin_wraps: HashMap<(u64, u64), (Vec<Arc<str>>, u32)>,
/// The same wrappers around the body's tables, by [`table_fingerprint`] (tables that are
/// the only one of their kind).
pub table_wraps: HashMap<u64, (Vec<Arc<str>>, u32)>,
/// While reading: those of the body's `n`th table, until `seal_kept`.
pub pending_table_wraps: Vec<(usize, (Vec<Arc<str>>, u32))>,
/// Indexes kept as read: by their first paragraph's fingerprint, how many top-level
/// paragraphs they take, those paragraphs' fingerprint together ([`run_fingerprint`]),
/// and the index's markup. Written as read while those paragraphs are all as read.
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
pub groups: HashMap<u64, (u32, u32)>,
/// While reading: the same by paragraph `rev`, until `seal_kept`.
pub pending_groups: HashMap<u64, (u32, u32)>,
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
/// showing one is written as read only while it is as read.
pub story_fps: HashMap<u32, u64>,
/// Each top-level comment's [`comments_fingerprint`] when read (it and its replies): a
/// paragraph holding comments is written as read only while they are as read.
pub comment_fps: HashMap<u32, u64>,
/// Named definitions the kept markup refers to (styles, font faces…), by a key the format
/// chooses (`"auto:P12"`, `"style:Heading_20_1"`), as read.
pub defs: BTreeMap<String, Arc<str>>,
/// While reading: paragraph [`Paragraph::rev`] → its markup, until [`Kept::seal`] turns
/// them into fingerprints.
pub pending: HashMap<u64, Arc<str>>,
/// Bytes kept so far.
pub bytes: usize,
/// The keys of [`KeptXml::defs`] in the order the file had them.
pub order: Vec<String>,
/// Files of the package kept definitions refer to (a style's fill picture), by path.
pub files: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's style definitions' [`styles_fingerprint`] when read: while it is the
/// same, the file's own style definitions can be written as they were.
pub styles_fp: Option<u64>,
/// The document's page setup's [`pages_fingerprint`] when read: while it is the same, the
/// file's own page layouts and master pages can be written as they were.
pub pages_fp: Option<u64>,
/// Each section's master page, as the file named it where its section starts (`None`: the
/// file's default), for a writer naming the file's own master pages.
pub section_masters: Vec<Option<String>>,
}
impl PartialEq for Kept {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Kept {
pub fn new(x: KeptXml) -> Kept {
Kept(Some(Arc::new(x)))
}
pub fn get(&self) -> Option<&KeptXml> {
self.0.as_deref()
}
/// What `format`'s writer may use, if anything was kept from such a file.
pub fn of(&self, format: &str) -> Option<&KeptXml> {
self.get().filter(|k| k.format == format)
}
pub fn get_mut(&mut self) -> Option<&mut KeptXml> {
self.0.as_mut().map(Arc::make_mut)
}
pub fn clear(&mut self) {
self.0 = None;
}
/// After reading: each paragraph still marked pending (by its `rev`, which any change after
/// the reader made it would have moved on) gets its markup under its fingerprint.
pub fn seal(&mut self, paras: &mut dyn Iterator<Item = &Paragraph>) {
let Some(k) = self.get_mut() else { return };
if k.pending.is_empty() {
return;
}
for p in paras {
if let Some(xml) = k.pending.remove(&p.rev) {
k.paras.entry(fingerprint(p)).or_insert(xml);
}
}
k.pending.clear();
}
}
impl KeptXml {
/// The markup kept for paragraph `p`, while it still is what was read.
pub fn para(&self, p: &Paragraph) -> Option<&Arc<str>> {
if self.paras.is_empty() {
return None;
}
self.paras.get(&fingerprint(p))
}
/// The markup kept for table `t`, while it still is what was read.
pub fn table(&self, t: &crate::Table) -> Option<&Arc<str>> {
if self.tables.is_empty() {
return None;
}
self.tables.get(&table_fingerprint(t))
}
/// Whether the comments paragraph `p` holds (those it begins, with their replies) are as
/// read and are all the comments its kept markup `xml` holds: a reply written elsewhere
/// in the file, or one added since, isn't in it.
pub fn comments_as_read(&self, doc: &crate::Document, p: &Paragraph, xml: &str) -> bool {
let in_markup = xml.matches("<office:annotation ").count() + xml.matches("<office:annotation>").count();
let mut count = 0usize;
for o in &p.objects {
if let crate::para::InlineObject::CommentStart { id } = o {
let (fp, n) = comments_fingerprint(doc, *id);
if self.comment_fps.get(id) != Some(&fp) {
return false;
}
count = count.saturating_add(n);
}
}
count == in_markup
}
/// Whether the stories paragraph `p` shows (its text boxes' and shapes' text, its notes') are
/// as read.
pub fn stories_as_read(&self, doc: &crate::Document, p: &Paragraph) -> bool {
// A note's citation is its number in the document's numbering, which is the page setup's.
let notes = p.objects.iter().any(|o| matches!(o, crate::para::InlineObject::NoteRef { .. }));
if notes && !self.pages_as_read(doc) {
return false;
}
p.objects
.iter()
.flat_map(|o| match o {
crate::para::InlineObject::NoteRef { id, .. } => vec![*id],
o => o.text_boxes(),
})
.all(|id| self.story_fps.get(&id).is_some_and(|fp| *fp == story_fingerprint(doc, id)))
}
/// The wrappers kept around table `t`, while it still is what was read.
pub fn table_wrap(&self, t: &crate::Table) -> Option<&(Vec<Arc<str>>, u32)> {
if self.table_wraps.is_empty() {
return None;
}
self.table_wraps.get(&table_fingerprint(t))
}
/// The wrappers kept around paragraph `p`, while it still is what was read.
pub fn wrap(&self, p: &Paragraph) -> Option<&(Vec<Arc<str>>, u32)> {
if self.wraps.is_empty() && self.twin_wraps.is_empty() {
return None;
}
let fp = fingerprint(p);
self.wraps.get(&fp).or_else(|| self.twin_wraps.get(&(fp, p.rev)))
}
/// The index kept as read starting at paragraph `p`: how many paragraphs it takes, their
/// [`run_fingerprint`] when read, its markup.
pub fn index(&self, p: &Paragraph) -> Option<&(usize, u64, Arc<str>)> {
if self.indexes.is_empty() {
return None;
}
self.indexes.get(&fingerprint(p))
}
/// Keep an index's markup, starting at the paragraph whose `rev` is `rev` and taking `n`.
pub fn keep_pending_index(&mut self, rev: u64, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_indexes.insert(rev, (n, xml));
}
}
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
if self.pending_groups.len() < 100_000 {
self.pending_groups.insert(rev, group);
}
}
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
if self.groups.is_empty() {
return None;
}
self.groups.get(&fingerprint(p)).copied()
}
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(n) <= MAX_KEPT_BYTES {
self.bytes += n;
self.pending_wraps.insert(rev, wrap);
}
}
/// Keep wrapper markup for the body's `n`th table, within the budget.
pub fn keep_pending_table_wrap(&mut self, n: usize, wrap: (Vec<Arc<str>>, u32)) {
let size: usize = wrap.0.iter().map(|t| t.len()).sum();
if self.bytes.saturating_add(size) <= MAX_KEPT_BYTES {
self.bytes += size;
self.pending_table_wraps.push((n, wrap));
}
}
/// Keep `xml` for the body's `n`th table, within the budget.
pub fn keep_pending_table(&mut self, n: usize, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending_tables.push((n, xml));
}
}
/// Keep `xml` for the paragraph whose `rev` is `rev`, within the budget.
pub fn keep_pending(&mut self, rev: u64, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES {
self.bytes += xml.len();
self.pending.insert(rev, xml);
}
}
/// Keep a named definition, within the budget.
pub fn keep_def(&mut self, key: String, xml: Arc<str>) {
if self.bytes.saturating_add(xml.len()) <= MAX_KEPT_BYTES && !self.defs.contains_key(&key) {
self.bytes += xml.len();
self.order.push(key.clone());
self.defs.insert(key, xml);
}
}
/// Keep a file kept definitions refer to, within the budget.
pub fn keep_file(&mut self, path: String, bytes: Vec<u8>) {
if self.bytes.saturating_add(bytes.len()) <= MAX_KEPT_BYTES && !self.files.contains_key(&path) {
self.bytes += bytes.len();
self.files.insert(path, Arc::new(bytes));
}
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
let Some(fp) = self.pages_fp else { return false };
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
fp == now
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
self.styles_fp.is_some_and(|fp| fp == styles_fingerprint(doc))
}
}
/// What `doc`'s style definitions are: its styles, lists and default tab interval.
pub fn styles_fingerprint(doc: &crate::Document) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(&doc.styles, &doc.numbering, doc.settings.default_tab)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a block is, as the kept markup knows it: its paragraphs' fingerprints and
/// [`Paragraph::rev`]s in order, and a table's [`table_fingerprint`].
pub struct Identities {
paras: Vec<(u64, u64)>,
table: Option<u64>,
}
/// A block's [`Identities`] before and after a change that keeps what it is.
pub type Moved = (Identities, Identities);
/// The [`Identities`] of block `b`.
pub fn identities(b: &crate::Block) -> Identities {
let mut paras_of = Vec::new();
match b {
crate::Block::Para(p) => paras_of.push(p),
crate::Block::Table(t) => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, 1, &mut paras_of);
}
}
}
}
Identities { paras: paras_of.iter().map(|p| (fingerprint(p), p.rev)).collect(), table: b.as_table().map(table_fingerprint) }
}
impl KeptXml {
/// The markup kept for a block's paragraphs and table follows them from `before` to `after`
/// (what they were and are: see [`crate::Document::fill_objects`]).
pub fn follow(&mut self, before: &Identities, after: &Identities) {
for ((fp, rev), (nfp, nrev)) in before.paras.iter().zip(&after.paras) {
if fp == nfp && rev == nrev {
continue;
}
if let Some(x) = self.paras.remove(fp) {
self.paras.entry(*nfp).or_insert(x);
}
if let Some(w) = self.wraps.remove(fp) {
self.wraps.entry(*nfp).or_insert(w);
}
if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) {
self.twin_wraps.entry((*nfp, *nrev)).or_insert(w);
}
// Its group too: all a group's paragraphs are written as read, or none.
if let Some(g) = self.groups.remove(fp) {
self.groups.entry(*nfp).or_insert(g);
}
}
if let (Some(t), Some(nt)) = (before.table, after.table)
&& t != nt
{
if let Some(x) = self.tables.remove(&t) {
self.tables.entry(nt).or_insert(x);
}
if let Some(w) = self.table_wraps.remove(&t) {
self.table_wraps.entry(nt).or_insert(w);
}
}
}
}
/// How deep [`story_fingerprint`] follows stories in stories (text boxes in text boxes, notes).
const MAX_STORY_DEPTH: usize = 8;
/// What story `id` (a text box's or a shape's text) is: its blocks, and the stories and notes
/// they hold in turn.
pub fn story_fingerprint(doc: &crate::Document, id: u32) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut todo = vec![(id, 0usize)];
let mut seen = std::collections::HashSet::new();
while let Some((id, depth)) = todo.pop() {
if !seen.insert(id) {
continue;
}
let blocks = doc.parts.get(&id).map(|p| &p.blocks);
match serde_json::to_vec(&(id, blocks)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
if depth >= MAX_STORY_DEPTH {
continue;
}
let Some(blocks) = blocks else { continue };
let mut held = Vec::new();
paras(blocks, 0, &mut held);
for p in held {
for o in &p.objects {
todo.extend(o.text_boxes().into_iter().map(|s| (s, depth + 1)));
if let crate::para::InlineObject::NoteRef { id, .. } = o {
todo.push((*id, depth + 1));
}
}
}
}
h.finish()
}
/// Most replies a comment's fingerprint follows (hostile input).
const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &scoped_replies(doc))
}
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
struct Scope {
/// The document's address: it can't move or change while borrowed for the writing.
doc: usize,
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
pages: Option<u64>,
}
thread_local! {
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
/// How many times [`replies`] was built (tests).
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = prev);
}
}
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
let key = std::ptr::from_ref(doc) as usize;
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
let _restore = Restore(prev);
f()
}
/// The scope for `doc`, if it is being written: `f` of it.
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
let key = std::ptr::from_ref(doc) as usize;
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
}
/// [`replies`], built once while `doc` is being written.
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
#[cfg(test)]
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
out.entry(p).or_default().push(*id);
}
}
out
}
/// [`comments_fingerprint`], with each comment's `replies`.
fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, Vec<u32>>) -> (u64, usize) {
let mut ids = vec![root];
let mut seen = std::collections::HashSet::from([root]);
let mut i = 0;
while let Some(&id) = ids.get(i) {
for r in replies.get(&id).map(Vec::as_slice).unwrap_or(&[]) {
// (A reply naming itself, or a circle of them, is followed once.)
if ids.len() < MAX_REPLIES && seen.insert(*r) {
ids.push(*r);
}
}
i += 1;
}
let mut h = std::collections::hash_map::DefaultHasher::new();
for id in &ids {
let c = doc.comments.get(id);
let text = c.and_then(|c| doc.parts.get(&c.part)).map(|p| &p.blocks);
match serde_json::to_vec(&(c, text)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
}
(h.finish(), ids.len())
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
let parts: Vec<(&u32, &crate::Part)> =
doc.parts.iter().filter(|(_, p)| matches!(p.kind, crate::PartKind::Header | crate::PartKind::Footer)).collect();
let s = &doc.settings;
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(&(
sections,
parts,
&s.page_color,
&s.page_picture,
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What a run of paragraphs is in the model, together.
pub fn run_fingerprint<'a>(paras: impl Iterator<Item = &'a Paragraph>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in paras {
fingerprint(p).hash(&mut h);
}
h.finish()
}
/// What table `t` is in the model.
pub fn table_fingerprint(t: &crate::Table) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(t) {
Ok(v) => v.hash(&mut h),
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// What paragraph `p` is in the model (its text, runs, objects and properties, not its `rev`):
/// equal for a paragraph nobody changed.
pub fn fingerprint(p: &Paragraph) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
match serde_json::to_vec(p) {
Ok(v) => v.hash(&mut h),
// Unserializable (no paragraph is): a value no paragraph's markup is kept under.
Err(_) => u64::MAX.hash(&mut h),
}
h.finish()
}
/// Deepest table nesting [`crate::Document::seal_kept`] looks into.
const MAX_SEAL_DEPTH: usize = 16;
fn paras<'a>(bl: &'a crate::Blocks, depth: usize, out: &mut Vec<&'a Paragraph>) {
for b in bl {
match &**b {
crate::Block::Para(p) => out.push(p),
crate::Block::Table(t) if depth < MAX_SEAL_DEPTH => {
for r in &t.rows {
for c in &r.cells {
paras(&c.blocks, depth + 1, out);
}
}
}
crate::Block::Table(_) => {}
}
}
}
impl crate::Document {
/// After reading: the markup kept for paragraphs still as the reader made them is filed
/// under their fingerprints (see [`Kept::seal`]); the rest is dropped.
pub fn seal_kept(&mut self) {
// The body's tables kept, by their place among its tables; its indexes, by their run of
// top-level paragraphs.
if let Some(k) = self.kept.get_mut() {
let pending = std::mem::take(&mut k.pending_indexes);
for (i, b) in self.body.iter().enumerate() {
let Some(p) = b.as_para() else { continue };
let Some((n, xml)) = pending.get(&p.rev) else { continue };
let run: Option<Vec<&Paragraph>> = self.body.get(i..i + n).map(|r| r.iter().filter_map(|b| b.as_para()).collect());
if let Some(run) = run.filter(|r| r.len() == *n) {
k.indexes.entry(fingerprint(p)).or_insert((*n, run_fingerprint(run.into_iter()), xml.clone()));
}
}
let pending = std::mem::take(&mut k.pending_tables);
let tables: Vec<&crate::Table> = self.body.iter().filter_map(|b| b.as_table()).collect();
for (n, xml) in pending {
if let Some(t) = tables.get(n) {
k.tables.entry(table_fingerprint(t)).or_insert(xml);
}
}
// Wrappers, for tables that are the only one of their kind (a copy can't open a
// section twice).
let pending = std::mem::take(&mut k.pending_table_wraps);
if !pending.is_empty() {
let fps: Vec<u64> = tables.iter().map(|t| table_fingerprint(t)).collect();
for (n, w) in pending {
if let Some(fp) = fps.get(n)
&& fps.iter().filter(|f| *f == fp).count() == 1
{
k.table_wraps.insert(*fp, w);
}
}
}
}
// The text boxes' and shapes' stories as read, for paragraphs showing them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) {
let fps: HashMap<u32, u64> = self
.parts
.iter()
.filter(|(_, p)| matches!(p.kind, crate::PartKind::TextBox | crate::PartKind::Footnote | crate::PartKind::Endnote))
.map(|(id, _)| (*id, story_fingerprint(self, *id)))
.collect();
if let Some(k) = self.kept.get_mut() {
k.story_fps = fps;
}
}
// The comments as read, for paragraphs holding them.
if self.kept.get().is_some_and(|k| !k.pending.is_empty()) && !self.comments.is_empty() {
let replies = replies(self);
let fps: HashMap<u32, u64> =
self.comments.iter().filter(|(_, c)| c.parent.is_none()).map(|(id, _)| (*id, family_fingerprint(self, *id, &replies).0)).collect();
if let Some(k) = self.kept.get_mut() {
k.comment_fps = fps;
}
}
if self.kept.get().is_none_or(|k| k.pending.is_empty() && k.pending_wraps.is_empty()) {
return;
}
let mut all = Vec::new();
paras(&self.body, 0, &mut all);
for p in self.parts.values() {
paras(&p.blocks, 0, &mut all);
}
let mut kept = std::mem::take(&mut self.kept);
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
// tell apart) isn't kept.
if let Some(k) = kept.get_mut()
&& !k.pending_groups.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
let pending = std::mem::take(&mut k.pending_groups);
let mut broken = std::collections::HashSet::new();
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
for (rev, fp) in &fps {
if let Some(g) = pending.get(rev) {
if count.get(fp) != Some(&1) {
broken.insert(g.0);
}
members.push((*rev, *fp, *g));
}
}
for (rev, fp, g) in members {
if broken.contains(&g.0) {
k.pending.remove(&rev);
} else {
k.groups.insert(fp, g);
}
}
}
// Wrappers, for paragraphs that are the only one of their kind.
if let Some(k) = kept.get_mut()
&& !k.pending_wraps.is_empty()
{
let mut count: HashMap<u64, usize> = HashMap::new();
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
for (_, fp) in &fps {
*count.entry(*fp).or_default() += 1;
}
for (rev, fp) in fps {
if let Some(w) = k.pending_wraps.remove(&rev) {
if count.get(&fp) == Some(&1) {
k.wraps.insert(fp, w);
} else {
k.twin_wraps.insert((fp, rev), w);
}
}
}
k.pending_wraps.clear();
}
kept.seal(&mut all.into_iter());
self.kept = kept;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_paragraph_keeps_its_markup_until_it_changes() {
let mut p = Paragraph::with_text("Hello", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hello</text:p>"));
}
kept.seal(&mut std::iter::once(&p));
let k = kept.of("odt").unwrap();
assert_eq!(k.para(&p).map(|x| &**x), Some("<text:p>Hello</text:p>"));
assert!(k.pending.is_empty());
// The same paragraph again (a copy, its rev moved on): still what was read.
p.touch();
assert!(k.para(&p).is_some());
// Changed: written from the model.
p.text.push('!');
assert!(k.para(&p).is_none());
assert!(kept.of("docx").is_none());
}
#[test]
fn a_paragraph_changed_while_reading_keeps_nothing() {
let mut p = Paragraph::with_text("Hi", Default::default());
let mut kept = Kept::new(KeptXml { format: "odt".into(), ..Default::default() });
if let Some(k) = kept.get_mut() {
k.keep_pending(p.rev, Arc::from("<text:p>Hi</text:p>"));
}
p.touch();
kept.seal(&mut std::iter::once(&p));
assert!(kept.of("odt").unwrap().paras.is_empty());
}
#[test]
fn kept_markup_is_bounded() {
let mut k = KeptXml::default();
let big: Arc<str> = Arc::from("x".repeat(MAX_KEPT_BYTES / 2 + 1));
k.keep_pending(1, big.clone());
k.keep_pending(2, big);
assert_eq!(k.pending.len(), 1);
}
/// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data)
/// doesn't change the document: a paragraph's group and a text box's story stay as read.
#[test]
fn filling_objects_keeps_groups_and_stories_as_read() {
use crate::para::InlineObject;
let field =
|| InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
let mut doc = crate::Document::from_text("Body");
let mut boxed = Paragraph::with_text("In the box", Default::default());
boxed.insert_object(0, field(), &Default::default()).unwrap();
let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]);
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap();
let fp = fingerprint(doc.body[0].as_para().unwrap());
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
k.paras.insert(fp, Arc::from("<text:p>Body</text:p>"));
k.groups.insert(fp, (1, 1));
k.story_fps.insert(id, story_fingerprint(&doc, id));
doc.kept = Kept::new(k);
doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| {
if let InlineObject::Field { result, .. } = o {
*result = "filled".into();
}
});
let p = doc.body[0].as_para().unwrap();
assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved");
let k = doc.kept.get().unwrap();
assert!(k.para(p).is_some());
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
/// Writing a document checks each kept paragraph's comments against what was read: the
/// comments' replies are looked up once for the whole document, not once per comment.
#[test]
fn writing_many_comments_looks_their_replies_up_once() {
use crate::para::InlineObject;
let n = 50u32;
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
let mut doc = crate::Document::from_text(&text);
for i in 0..n {
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
.unwrap()
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
.unwrap();
}
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
for i in 0..n {
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
}
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
let before = REPLY_BUILDS.with(std::cell::Cell::get);
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
assert!(all, "every paragraph's comment is as read");
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
// Outside a writing, nothing is kept between calls.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
}
}
+17 -6
View File
@@ -2,9 +2,9 @@
//!
//! A [`Document`] holds the main story (`body`) and secondary stories (`parts`: headers,
//! footers, notes, comments, text boxes). A story is a list of [`Block`]s: paragraphs and tables;
//! table cells hold block lists of their own. Blocks 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.
@@ -28,6 +28,7 @@ pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod kept;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -39,6 +40,7 @@ pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod para;
pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
@@ -47,6 +49,7 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
@@ -84,7 +87,7 @@ pub use error::{DocError, Result};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
@@ -98,8 +101,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,
@@ -127,6 +134,10 @@ pub struct Document {
/// by `==`).
#[serde(skip)]
media_index: MediaIndex,
/// Markup kept from the file it was opened from, for writing back what isn't edited (see
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
}
#[cfg(test)]
+114 -2
View File
@@ -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"] {
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
+3
View File
@@ -19,6 +19,9 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
fx: crate::picfx::PicFx,
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
+190
View File
@@ -0,0 +1,190 @@
//! Picture colour corrections read from a file: brightness, contrast and transparency
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
//!
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
//! the picture as it is.
//!
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
//! only at the end:
//!
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
//!
//! (as if half the brightness shift were applied before the contrast and half after). Measured
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
//!
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
use serde::{Deserialize, Serialize};
/// The largest magnitude of a correction (100 %).
pub const FX_MAX: i32 = 100_000;
/// Brightness, contrast and transparency of a picture (see the module docs).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct PicFx {
/// −100 000 (black) ..= 100 000 (white).
pub bright: i32,
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
pub contrast: i32,
/// 0 (opaque) ..= 100 000 (invisible).
pub transparency: i32,
}
impl PicFx {
/// Corrections clamped to their ranges.
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
}
/// The same corrections, each clamped to its range (for values that came from anywhere).
pub fn clamped(self) -> PicFx {
PicFx::new(self.bright, self.contrast, self.transparency)
}
/// No correction at all.
pub fn is_identity(&self) -> bool {
*self == PicFx::default()
}
/// Brightness or contrast changes the pixels (transparency alone doesn't).
pub fn changes_pixels(&self) -> bool {
let f = self.clamped();
f.bright != 0 || f.contrast != 0
}
/// The picture's opacity, 0..=1 (1 − transparency).
pub fn opacity(&self) -> f32 {
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
}
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
/// black level (see the module docs). Non-finite values count as missing.
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
PicFx::new(fx(2.0 * black), fx(c), 0)
}
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
pub fn to_vml(&self) -> (f64, f64) {
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
}
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
fn slope(&self) -> f64 {
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
}
/// The lookup table every colour channel goes through.
pub fn table(&self) -> [u8; 256] {
let s = self.slope();
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
let mut t = [0u8; 256];
for (x, slot) in t.iter_mut().enumerate() {
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
}
t
}
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
/// trailing partial pixel is ignored.
pub fn apply_rgba(&self, rgba: &mut [u8]) {
if !self.changes_pixels() {
return;
}
let t = self.table();
for px in rgba.as_chunks_mut::<4>().0 {
for c in px.iter_mut().take(3) {
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
#[test]
fn matches_word() {
let cases: [(PicFx, [u8; 3]); 6] = [
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
];
for (fx, want) in cases {
let t = fx.table();
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
let got = t[x];
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
}
}
}
#[test]
fn channels_apart_alpha_kept() {
let fx = PicFx::new(80_000, -80_000, 0);
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
fx.apply_rgba(&mut px);
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
let mut odd = [0u8; 6];
fx.apply_rgba(&mut odd);
assert_eq!(odd[4..], [0, 0]);
}
#[test]
fn vml_mapping() {
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
let fx = PicFx::from_vml(g, b);
assert_eq!(fx, fx.clamped(), "{g} {b}");
let _ = fx.table();
}
}
#[test]
fn hostile_values_clamp() {
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
let fx = PicFx { bright: v, contrast: v, transparency: v };
let _ = fx.table();
assert!((0.0..=1.0).contains(&fx.opacity()));
let mut px = [1u8, 2, 3, 4];
fx.apply_rgba(&mut px);
assert_eq!(px[3], 4);
}
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
// Contrast 100 %: a threshold at mid-grey, no NaN.
let t = PicFx::new(0, FX_MAX, 0).table();
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
}
}
+3 -2
View File
@@ -23,12 +23,13 @@ mod para_props;
mod table_props;
pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use table_props::{
CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor,
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
};
#[cfg(test)]
+41 -2
View File
@@ -291,6 +291,10 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// A mark over (or under) each character (`w:em`, Font › Emphasis mark), as East Asian text
/// stresses words; `Some(EmphasisMark::None)` turns off one a style sets.
#[serde(skip_serializing_if = "Option::is_none")]
pub emphasis: Option<EmphasisMark>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -381,6 +385,41 @@ pub struct EastAsianLayout {
pub vert_compress: Option<bool>,
}
/// A mark over or under each character (`ST_Em`, ECMA-376 §17.18.24): Word's Font › Emphasis
/// mark.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum EmphasisMark {
#[default]
None,
/// A dot above.
Dot,
/// A comma (sesame) above.
Comma,
/// A hollow circle above.
Circle,
/// A dot below.
UnderDot,
}
impl EmphasisMark {
pub const ALL: [EmphasisMark; 5] = [EmphasisMark::None, EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot];
/// OOXML `w:em` value.
pub fn ooxml(self) -> &'static str {
match self {
EmphasisMark::None => "none",
EmphasisMark::Dot => "dot",
EmphasisMark::Comma => "comma",
EmphasisMark::Circle => "circle",
EmphasisMark::UnderDot => "underDot",
}
}
/// The mark an OOXML `w:em` value names; unknown values are none.
pub fn from_ooxml(v: &str) -> EmphasisMark {
EmphasisMark::ALL.into_iter().find(|m| m.ooxml() == v.trim()).unwrap_or_default()
}
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -466,7 +505,7 @@ impl CharProps {
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid);
drop_same_fields!(self, base; web_hidden, snap_to_grid, emphasis);
}
fn minus_script_style(&mut self, base: &CharProps) {
@@ -497,7 +536,7 @@ impl CharProps {
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid);
overlay_fields!(self, patch; web_hidden, snap_to_grid, emphasis);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
+35
View File
@@ -107,6 +107,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +133,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +154,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -183,6 +213,11 @@ impl Borders {
pub fn any_visible(&self) -> bool {
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
}
/// A side is drawn, or a paragraph side without a line holds padding (ODF's, which keeps
/// its room in LibreOffice's layout).
pub fn takes_room(&self) -> bool {
self.any_visible() || [self.top, self.left, self.bottom, self.right].iter().flatten().any(|b| b.space.is_finite() && b.space > 0.0)
}
/// Apply every `Some` side of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &Borders) {
if patch.top.is_some() {
+38
View File
@@ -158,6 +158,12 @@ pub struct TableProps {
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
/// Space above and below a table in the text flow, points (ODF's table `fo:margin-top` and
/// `fo:margin-bottom`; Word has none). Added to the spacing of the paragraphs around it.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
}
/// Where a floating table sits; the text after it wraps around it.
@@ -294,6 +300,38 @@ pub struct CellProps {
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
/// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has
/// none).
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<Box<CellPicture>>,
}
/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched
/// over the cell, and cut off at the cell's edges.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CellPicture {
/// Key into [`crate::Document::media`].
pub media: String,
/// Its own size, points.
pub size: [f32; 2],
pub repeat: PictureRepeat,
/// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right
/// (bottom) edge.
pub align: [f32; 2],
}
/// How a background picture fills its area (ODF §20.348 `style:repeat`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PictureRepeat {
/// Once, at its own size.
#[default]
None,
/// Repeated across the area.
Tile,
/// Stretched over the area.
Stretch,
}
impl CellProps {
+65
View File
@@ -41,6 +41,9 @@ pub struct ResolvedChar {
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// A mark over or under each character (Font › Emphasis mark).
#[serde(default)]
pub emphasis: crate::props::EmphasisMark,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
@@ -97,6 +100,7 @@ impl ResolvedChar {
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
emphasis: c.emphasis.unwrap_or_default(),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
@@ -178,6 +182,10 @@ pub struct ResolvedPara {
pub tabs: Vec<TabStop>,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model
/// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it.
#[serde(default)]
pub padding: [f32; 4],
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
@@ -265,6 +273,10 @@ impl ResolvedPara {
tabs,
shading: p.shading,
borders: p.borders.filter(Borders::any_visible),
padding: p.borders.map_or([0.0; 4], |b| {
[b.top, b.left, b.bottom, b.right]
.map(|s| s.filter(|l| !l.is_visible() && l.space.is_finite() && l.space > 0.0).map_or(0.0, |l| l.space.min(1584.0)))
}),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
@@ -323,6 +335,38 @@ impl StyleSheet {
(pp, cp)
}
/// What a table style's paragraph formatting `table` changes for a paragraph with `p`, once
/// the paragraph style chain is applied over it (ECMA-376 §17.7.2): each property it gives
/// that the chain doesn't set. Formats without table styles state these directly.
pub fn table_para_patch(&self, p: &ParaProps, table: &ParaProps) -> ParaProps {
let style = p.style.clone().unwrap_or_else(|| "Normal".into());
let mut with = self.default_para.clone();
with.overlay(&ParaProps { style: None, ..table.clone() });
let (with, _) = self.para_style_props_over(&style, with, CharProps::default());
let (without, _) = self.para_style_props_over(&style, self.default_para.clone(), CharProps::default());
diff_patch(&with, &without)
}
/// What a table style's run formatting `table` changes for a run of character style
/// `run_style` in a paragraph of style `para_style`, once the paragraph and character style
/// chains are applied over it (ECMA-376 §17.7.2).
pub fn table_char_patch(&self, para_style: Option<&str>, run_style: Option<&str>, table: &CharProps) -> CharProps {
let chain = |defaults: CharProps| {
let (_, mut cp) = self.para_style_props_over(para_style.unwrap_or("Normal"), ParaProps::default(), defaults);
if let Some(cs) = run_style {
for s in self.chain(cs) {
if s.kind == StyleKind::Character || s.kind == StyleKind::Paragraph {
cp.overlay(&s.chr);
}
}
}
cp
};
let mut with = self.default_chr.clone();
with.overlay(&CharProps { style: None, ..table.clone() });
diff_patch(&chain(with), &chain(self.default_chr.clone()))
}
/// Resolve a paragraph's properties.
pub fn resolve_para(&self, p: &ParaProps) -> ResolvedPara {
self.resolve_para_in(p, None)
@@ -387,6 +431,16 @@ impl StyleSheet {
}
}
/// The properties of `with` that differ from `without` (both fully resolved property sets of one
/// kind), as a set of only those; empty when serialisation fails.
fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, without: &T) -> T {
let (Ok(serde_json::Value::Object(w)), Ok(serde_json::Value::Object(o))) = (serde_json::to_value(with), serde_json::to_value(without)) else {
return T::default();
};
let patch: serde_json::Map<String, serde_json::Value> = w.into_iter().filter(|(k, v)| o.get(k) != Some(v)).collect();
serde_json::from_value(serde_json::Value::Object(patch)).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -524,4 +578,15 @@ mod tests {
assert_eq!(p.tabs.len(), 3);
assert_eq!(p.tabs[0].pos, 100.0);
}
/// Resolved formatting saved before paragraph padding existed still reads: no padding.
#[test]
fn resolved_para_without_padding_reads() {
let p = StyleSheet::builtin().resolve_para(&ParaProps::default());
let mut v = serde_json::to_value(&p).unwrap();
v.as_object_mut().unwrap().remove("padding");
let back: ResolvedPara = serde_json::from_value(v).unwrap();
assert_eq!(back.padding, [0.0; 4]);
assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p });
}
}
+19
View File
@@ -733,6 +733,13 @@ pub struct SectionProps {
/// Distance from the page edge to the header / footer.
pub header: f32,
pub footer: f32,
/// The space kept between a header and the body, and between the body and a footer, points
/// (ODF's header and footer spacing; Word keeps none). The margin already holds the band and
/// its spacing, so it only moves the body once content and spacing reach past the margin.
#[serde(skip_serializing_if = "is_zero")]
pub header_gap: f32,
#[serde(skip_serializing_if = "is_zero")]
pub footer_gap: f32,
pub gutter: f32,
/// Mirror margins (inside/outside) — set from the document settings.
pub columns: Columns,
@@ -772,6 +779,11 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub balance_columns: Option<bool>,
}
impl Default for SectionProps {
@@ -788,6 +800,8 @@ impl Default for SectionProps {
margin_right: 72.0,
header: 36.0,
footer: 36.0,
header_gap: 0.0,
footer_gap: 0.0,
gutter: 0.0,
columns: Columns::default(),
headers: HeaderSet::default(),
@@ -807,10 +821,15 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
balance_columns: None,
}
}
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_horizontal(d: &crate::props::TextDirection) -> bool {
!d.is_turned()
}
+22
View File
@@ -46,6 +46,10 @@ pub struct CustomProp {
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
/// Declared in an ODF document as a user field (§7.4.7 `text:user-field-decl`) rather than
/// a document property: written back as one.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub odf_user_field: bool,
}
/// Document properties (File › Info).
@@ -162,6 +166,23 @@ pub struct Settings {
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<OdfLayout>,
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
/// the file says about laying it out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
/// explicit stops are held from the margin either way; the default ones are counted from the
/// indent.
pub tabs_from_indent: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
@@ -307,6 +328,7 @@ impl Default for Settings {
zoom: None,
remove_personal_info: false,
remove_date_time: false,
odf: None,
}
}
}
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+34
View File
@@ -232,6 +232,40 @@ impl PartSet {
}
}
/// A cell's borders from the table style's parts it is in, later parts winning. Row parts give
/// their left/right edges at the table's sides and `inside_v` between cells; column parts their
/// top/bottom edges at the table's top and bottom and `between` between rows; corner cells their
/// four edges.
pub fn part_borders(parts: &[(TablePart, Borders)], (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool)) -> Borders {
let mut out = Borders::default();
for (part, b) in parts {
let b = *b;
let mapped = if part.is_row() {
Borders {
top: b.top,
bottom: b.bottom,
left: if first_g { b.left } else { b.inside_v },
right: if last_g { b.right } else { b.inside_v },
between: None,
inside_v: None,
}
} else if part.is_column() {
Borders {
top: if ri == 0 { b.top } else { b.between },
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
left: b.left,
right: b.right,
between: None,
inside_v: None,
}
} else {
Borders { between: None, inside_v: None, ..b }
};
out.overlay(&mapped);
}
out
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
+6 -4
View File
@@ -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
View File
@@ -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 });
+28
View File
@@ -0,0 +1,28 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
#[inline(always)]
pub(crate) fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
+8 -7
View File
@@ -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());
+6 -82
View File
@@ -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,
@@ -621,6 +623,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
alt,
float: Default::default(),
crop: p.crop,
fx: Default::default(),
ole: None,
});
}
@@ -722,85 +725,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) {
+3 -23
View File
@@ -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.
+71 -7
View File
@@ -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`
+1
View File
@@ -391,6 +391,7 @@ fn a_floating_copy_is_charged_its_alt_text() {
alt,
float: Default::default(),
crop: Default::default(),
fx: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
+14 -2
View File
@@ -25,11 +25,23 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: kind.to_string(),
value: v.text(),
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
CustomProp {
name: name.to_string(),
kind: "raw".into(),
value: xml,
link_target: p.attr("linkTarget").map(str::to_string),
odf_user_field: false,
}
};
out.push(prop);
}
+38 -30
View File
@@ -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);
}
@@ -250,6 +254,7 @@ impl PropCtx {
"w:cs" => c.cs = Some(on_off(k)),
"w:caps" => c.caps = Some(on_off(k)),
"w:smallCaps" => c.small_caps = Some(on_off(k)),
"w:em" => c.emphasis = Some(k.attr("w:val").map(wordcraft_doc::props::EmphasisMark::from_ooxml).unwrap_or_default()),
"w:strike" => c.strike = Some(on_off(k)),
"w:dstrike" => c.double_strike = Some(on_off(k)),
"w:outline" => c.outline = Some(on_off(k)),
@@ -282,7 +287,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 +366,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 +481,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 +540,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 +646,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 +683,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 +788,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) {
+3 -3
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in &mut first.runs {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
@@ -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 -1
View File
@@ -263,7 +263,7 @@ impl Reader<'_> {
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
+1 -63
View File
@@ -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 ----
+4 -1
View File
@@ -134,7 +134,10 @@ impl Reader<'_> {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
+7 -6
View File
@@ -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(())
}
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+23
View File
@@ -84,6 +84,29 @@ pub fn int(s: &str) -> Option<i64> {
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
+15 -2
View File
@@ -317,7 +317,8 @@ impl<'a> Conv<'a> {
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
@@ -329,7 +330,19 @@ impl<'a> Conv<'a> {
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
let fx = crate::units::blip_fx(blip);
if fx.changes_pixels()
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
{
let (gain, black) = fx.to_vml();
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
data.attrs.push(("gain".into(), fixed(gain)));
data.attrs.push(("blacklevel".into(), fixed(black)));
}
kids.push(Node::El(shape));
vec![Node::El(plain("w:pict", kids))]
}
+4 -2
View File
@@ -294,8 +294,10 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels,
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
// VML has no centred fill (an ODT page background's): stretched to the page.
let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() };
if !kind.is_empty() {
fa.push(("type", kind));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
+4
View File
@@ -108,6 +108,10 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
}
toggle(w, "w:rtl", c.rtl);
toggle(w, "w:cs", c.cs);
// Emphasis mark (§17.3.2.12), after `w:cs` and before `w:lang`.
if let Some(m) = c.emphasis {
w.val("w:em", m.ooxml());
}
let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
if let Some(l) = &c.lang {
lang_attrs.push(("w:val", l));
+16 -2
View File
@@ -15,7 +15,7 @@ pub(super) struct DrawingInfo<'a> {
impl Writer<'_> {
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
@@ -26,7 +26,21 @@ impl Writer<'_> {
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
let fx = fx.clamped();
if fx.is_identity() {
w.empty("a:blip", &[("r:embed", &rid)]);
} else {
w.open("a:blip", &[("r:embed", &rid)]);
if fx.transparency != 0 {
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
}
if fx.bright != 0 || fx.contrast != 0 {
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
}
w.close("a:blip");
}
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
+1 -1
View File
@@ -249,7 +249,7 @@ impl Writer<'_> {
fn write_image(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, _depth: usize) {
let InlineObject::Image { media, w: iw, h: ih, alt, float, ole, .. } = o else { return };
// An OLE object read from a file: the object itself, at its current size.
if let Some(src) = ole.as_deref()
if let Some(src) = ole.as_deref().filter(|e| !e.is_odf_object())
&& let Some(obj) = self.embedded_xml(src, rels, Some((*iw, *ih, float, alt.as_str())))
{
self.rev_open(w, props);
+10 -1
View File
@@ -317,7 +317,16 @@ fn a_chunks_pictures_come_with_it() {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
let image = InlineObject::Image {
media: key,
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
+2 -1
View File
@@ -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)]);
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+5 -4
View File
@@ -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
View File
@@ -22,6 +22,7 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Emphasis marks (`w:em`, ISO/IEC 29500-1 §17.3.2.12: Font › Emphasis mark) are read and
//! saved again, in their place in `w:rPr`. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::EmphasisMark;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in [("_rels/.rels", root.to_string()), ("word/document.xml", doc)] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(xml.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn marks(d: &wordcraft_doc::Document) -> Vec<Option<EmphasisMark>> {
d.body[0].as_para().unwrap().runs.iter().map(|r| r.props.emphasis).collect()
}
#[test]
fn emphasis_marks_are_read_and_saved() {
let run = |t: &str, em: &str| format!(r#"<w:r><w:rPr><w:cs/>{em}<w:lang w:val="ja-JP"/></w:rPr><w:t>{t}</w:t></w:r>"#);
let body = format!(
"<w:p>{}{}{}{}{}{}</w:p>",
run("a", r#"<w:em w:val="dot"/>"#),
run("b", r#"<w:em w:val="comma"/>"#),
run("c", r#"<w:em w:val="circle"/>"#),
run("d", r#"<w:em w:val="underDot"/>"#),
run("e", r#"<w:em w:val="none"/>"#),
run("f", r#"<w:em w:val="sparkle"/>"#),
);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
// An unknown mark is none (and the run with it joins the one before, now alike).
let want = [EmphasisMark::Dot, EmphasisMark::Comma, EmphasisMark::Circle, EmphasisMark::UnderDot, EmphasisMark::None].map(Some);
assert_eq!(marks(&d), want);
let saved = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&saved)).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
// In its place: after w:cs, before w:lang.
assert!(xml.contains(r#"<w:cs/><w:em w:val="underDot"/><w:lang"#), "{xml}");
assert_eq!(marks(&wordcraft_docx::read(&saved).unwrap()), want);
}
+216 -1
View File
@@ -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.
@@ -3878,6 +3941,144 @@ fn hyperlink_with_empty_target_is_no_link() {
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
}
/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's
/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back.
#[test]
fn picture_brightness_contrast_transparency() {
use wordcraft_doc::picfx::PicFx;
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
let vml =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape style="width:1in;height:36pt"><v:imagedata r:id="rIdImg" {attrs}/></v:shape></w:pict></w:r>"#);
let dml = |blip: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg">{blip}</a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let cases: Vec<(String, Option<PicFx>)> = vec![
// Word's Washout: 0.3 and 0.35 in 16.16 fixed point.
(vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))),
(vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))),
(vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))),
(vml(""), Some(PicFx::default())),
(dml(r#"<a:lum bright="80000" contrast="-80000"/>"#), Some(PicFx::new(80_000, -80_000, 0))),
(dml(r#"<a:alphaModFix amt="40000"/><a:lum bright="-20000"/>"#), Some(PicFx::new(-20_000, 0, 60_000))),
(dml(""), Some(PicFx::default())),
// Hostile values: clamped, never a panic.
(vml(r#"gain="NaNf" blacklevel="infinity""#), None),
(vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None),
(vml(r#"gain="1e308" blacklevel="-1e308""#), None),
(vml(r#"gain="f" blacklevel="--3""#), None),
(dml(r#"<a:lum bright="999999999" contrast="-99999999999999999999"/><a:alphaModFix amt="-5"/>"#), None),
(dml(r#"<a:alphaModFix amt="250000"/><a:lum bright="x" contrast=""/>"#), None),
];
let body: String = cases.iter().map(|(x, _)| format!("<w:p>{x}</w:p>")).collect();
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdImg", "image", "media/pic.png")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/media/pic.png", png),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
let fx_of = |d: &Document| -> Vec<PicFx> {
paras(d)
.iter()
.map(|p| match p.objects.first() {
Some(InlineObject::Image { fx, .. }) => *fx,
o => panic!("{o:?}"),
})
.collect()
};
let read = fx_of(&d);
assert_eq!(read.len(), cases.len());
for ((x, want), got) in cases.iter().zip(&read) {
assert_eq!(*got, got.clamped(), "{x}");
if let Some(want) = want {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}");
}
}
// Clamped hostile values.
assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000));
assert_eq!(read[12], PicFx::default());
// Saved as DrawingML (VML pictures too) and read back unchanged.
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(fx_of(&back), read);
}
/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks
/// building block keeps its brightness and contrast.
#[test]
fn washout_picture_watermark_in_header() {
let hdr = format!(
r##"<w:hdr {W_NS}><w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Watermarks"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:200pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rImg" o:title="wm" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p></w:sdtContent></w:sdt></w:hdr>"##
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let id = d.last_section.headers.default.unwrap();
let fx = d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.expect("watermark picture");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads
/// and is written back once, as in the body.
#[test]
fn faded_picture_behind_text_in_header() {
let hdr = format!(
r#"<w:hdr {W_NS}><w:p><w:r><w:drawing><wp:anchor behindDoc="1" distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="2540000" cy="2540000"/><wp:wrapNone/><wp:docPr id="7" name="Faded"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rImg"><a:alphaModFix amt="20000"/></a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p></w:hdr>"#
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let header_picture = |d: &wordcraft_doc::Document| {
let id = d.last_section.headers.default.unwrap();
d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None })
.expect("header picture")
};
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let (fx, wrap) = header_picture(&d);
assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000));
assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText);
let saved = wordcraft_docx::write(&d).unwrap();
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving");
}
/// An OLE object's preview keeps the corrections of its picture (#1961 review).
#[test]
fn ole_preview_keeps_its_corrections() {
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rIdImg" gain="19661f" blacklevel="22938f"/></v:shape></w:object></w:r></w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap();
let fx = d.body[0]
.as_para()
.and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }))
.expect("the preview");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
#[test]
fn row_table_borders_round_trip() {
let d = read_body(
@@ -4313,6 +4514,20 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+2 -1
View File
@@ -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 {
+2
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -13,6 +14,7 @@ mod default_size;
mod drawing_payload;
mod east_asian_lang;
mod east_asian_layout;
mod emphasis_marks;
mod fixtures;
mod glossary;
mod heading_levels;
+3 -2
View File
@@ -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}");
}
+50 -15
View File
@@ -95,6 +95,7 @@ fn every_char_prop_round_trips() {
scale: Some(150.0),
position: Some(3.0),
kern: Some(8.0),
emphasis: Some(wordcraft_doc::props::EmphasisMark::Comma),
ligatures: Some(wordcraft_doc::props::Ligatures { standard: true, historical: true, ..Default::default() }),
outline: Some(true),
shadow: Some(true),
@@ -248,7 +249,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 +266,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 +275,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();
@@ -427,6 +428,7 @@ fn images_round_trip() {
// Room for a shadow: kept through save and load.
float: Float { effect: [12.0, 12.0, 27.0, 27.0], ..Default::default() },
crop: [0.1, 0.0, 0.25, 0.05],
fx: Default::default(),
ole: None,
};
let floating = InlineObject::Image {
@@ -436,6 +438,7 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Margin, x: 36.0, y: 12.5, dist: 9.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let aligned = InlineObject::Image {
@@ -455,6 +458,7 @@ fn images_round_trip() {
..Default::default()
},
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut floats = vec![floating.clone(), aligned];
@@ -466,6 +470,7 @@ fn images_round_trip() {
alt: String::new(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 0.0, y: 0.0, dist: 0.0, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
});
}
@@ -567,7 +572,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 +625,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 {
@@ -1188,7 +1193,16 @@ fn groups_round_trip() {
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let story = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("In the group", CharProps::default()))]);
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "A picture".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "A picture".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let oval = InlineObject::Shape {
kind: ShapeKind::Ellipse,
w: 50.0,
@@ -1275,7 +1289,16 @@ fn drawing_canvas_round_trips() {
use wordcraft_doc::para::{CanvasStyle, GroupChild};
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None };
let pic = InlineObject::Image {
media: key,
w: 60.0,
h: 40.0,
alt: "In the canvas".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let star = InlineObject::Shape {
kind: ShapeKind::Star,
w: 72.0,
@@ -1708,7 +1731,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()))]);
@@ -1733,7 +1756,16 @@ fn kitchen_sink() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "pic".into(), float: Float::default(), crop: [0.0; 4], ole: None },
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "pic".into(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
)
.unwrap();
@@ -1828,6 +1860,7 @@ fn hostile_model_values_still_write() {
alt: String::new(),
float: Float::default(),
crop: [f32::NAN; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
@@ -1940,10 +1973,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();
@@ -2098,6 +2131,7 @@ fn rotation_and_flips_round_trip() {
alt: String::new(),
float: Float { rot: 30.0, effect: [6.0; 4], ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |kind, float| InlineObject::Shape {
@@ -2598,6 +2632,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
alt: "A kiln at dusk".into(),
float: Float { alt: AltText { title: "Kiln".into(), ..Default::default() }, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let shape = |alt: AltText| InlineObject::Shape {
+1
View File
@@ -215,6 +215,7 @@ fn picture_in_box_keeps_its_frame() {
alt: "Synthetic marker".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
let mut p = Paragraph::new();
+11 -1
View File
@@ -106,7 +106,7 @@ fn wordml_2003_sample() -> String {
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
@@ -117,6 +117,16 @@ fn wordml_2003_sample() -> String {
)
}
/// Word 2003 XML pictures keep Word's Washout (gain 0.3, black level 0.35) as brightness and
/// contrast (#1711).
#[test]
fn word_2003_xml_picture_keeps_washout() {
let doc = read_xml(wordml_2003_sample().as_bytes()).unwrap();
let ps = paras(&doc);
let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. }));
assert!(matches!(pic, Some(InlineObject::Image { fx, .. }) if (fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2), "{pic:?}");
}
#[test]
fn imports_word_2003_xml() {
let xml = wordml_2003_sample();
+53 -2
View File
@@ -32,7 +32,7 @@ fn sample() -> Document {
d.core.title = "Garden <notes> & more".into();
d.core.creator = "Ada".into();
let key = d.add_media(tiny_png(), "png");
let num = 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()))]);
@@ -69,7 +69,16 @@ fn sample() -> Document {
let mut img = Paragraph::new();
img.insert_object(
0,
InlineObject::Image { media: key, w: 30.0, h: 20.0, alt: "dot".into(), float: Default::default(), crop: [0.0; 4], ole: None },
InlineObject::Image {
media: key,
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
},
&CharProps::default(),
)
.unwrap();
@@ -180,6 +189,48 @@ fn big_and_deep_documents_write() {
}
}
/// Picture brightness and contrast (#1711) survive a Word 2003 XML save as VML `gain` and
/// `blacklevel`; transparency has no place on `v:imagedata` and is dropped.
#[test]
fn picture_brightness_and_contrast_round_trip() {
use wordcraft_doc::picfx::PicFx;
let cases = [PicFx::new(70_000, -70_000, 0), PicFx::new(-30_000, 50_000, 40_000), PicFx::default()];
let mut d = Document::new();
let key = d.add_media(tiny_png(), "png");
d.body = cases
.iter()
.map(|fx| {
let mut p = Paragraph::new();
let pic = InlineObject::Image {
media: key.clone(),
w: 30.0,
h: 20.0,
alt: "dot".into(),
float: Default::default(),
crop: [0.0; 4],
fx: *fx,
ole: None,
};
p.insert_object(0, pic, &CharProps::default()).unwrap();
para_block(p)
})
.collect();
let xml = write_wordml2003(&d).expect("write");
let text = String::from_utf8_lossy(&xml);
assert!(text.contains(r#"gain="19661f""#) && text.contains(r#"blacklevel="22938f""#), "Word's Washout values");
let back = read_xml(&xml).expect("read");
let got: Vec<PicFx> = paras(&back)
.iter()
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.collect();
assert_eq!(got.len(), cases.len());
for (g, want) in got.iter().zip(cases) {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(g.bright, want.bright) && near(g.contrast, want.contrast) && g.transparency == 0, "{g:?} vs {want:?}");
}
}
#[test]
fn shared_header_and_repeated_note_are_not_inlined_per_reference() {
// #3652: one ~10 KB header shared by 1000 sections and one ~10 KB footnote referenced 500
+2
View File
@@ -44,6 +44,8 @@ getrandom = { workspace = true }
# Language packs (CP4.3): downloaded, verified and unpacked by the suite's font store.
craft-font-store = { workspace = true }
craft-font-catalog = { workspace = true }
# The local time zone's offset for Insert ▸ Date & Time and date fields (#1707); the web asks the browser's Date.
chrono = { workspace = true, features = ["clock"] }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
+1
View File
@@ -70,6 +70,7 @@ Home|Font|Character Shading|format.shading
Home|Font|Font Dialog|format.fontDialog
Home|Font|Double Strikethrough|format.doubleStrikethrough
Home|Font|Small Caps|format.smallCaps
Home|Font|Emphasis Mark|format.emphasisMark
Home|Font|All Caps|format.allCaps
Home|Font|Hidden|format.hidden
Home|Font|Emboss|format.emboss
+53
View File
@@ -548,6 +548,59 @@ pub fn now_iso() -> String {
iso_from_unix_secs(now_unix())
}
/// The local time zone's offset from UTC in seconds (east positive) at `unix`, daylight saving
/// included: the system's zone, or the browser's on the web. 0 (UTC) when it can't be found.
pub fn local_offset_secs(unix: u64) -> i64 {
system_offset_secs(unix).clamp(-26 * 3600, 26 * 3600)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_offset_secs(unix: u64) -> i64 {
use chrono::Offset;
let Some(utc) = i64::try_from(unix).ok().and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) else {
return 0;
};
// chrono's `Local` has internal `expect`s (unreadable zone data, an unmapped Windows year), so
// fence it: a failure means UTC, never a crash. A caught panic is still reported by the app's
// panic hook (logged, no abort); the document is untouched.
std::panic::catch_unwind(|| i64::from(utc.with_timezone(&chrono::Local).offset().fix().local_minus_utc())).unwrap_or(0)
}
/// On the web the browser's `Date` gives the offset directly (chrono isn't used there).
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
fn system_offset_secs(unix: u64) -> i64 {
let ms = (unix as f64) * 1000.0;
if !ms.is_finite() {
return 0;
}
// `getTimezoneOffset` is minutes *west* of UTC (UTC+13 gives -780); NaN for an invalid date.
let west_min = js_sys::Date::new(&js_sys::wasm_bindgen::JsValue::from_f64(ms)).get_timezone_offset();
if west_min.is_finite() && west_min.abs() <= 26.0 * 60.0 { -(west_min as i64) * 60 } else { 0 }
}
/// Other wasm targets (WASI) have neither chrono's clock nor a browser: UTC.
#[cfg(all(target_arch = "wasm32", not(target_os = "unknown")))]
fn system_offset_secs(_unix: u64) -> i64 {
0
}
/// The local civil date and time now: see [`local_civil`].
pub fn local_now() -> (i64, u32, u32, (u32, u32, u32), i64) {
let now = now_unix();
local_civil(now, local_offset_secs(now))
}
/// Local civil date and time for an instant: (year, month, day, (hour, minute, second), days since
/// 1970-01-01 of that local day, for the weekday). `offset_secs` is the local zone's offset from
/// UTC (east positive). Saturating, so a hostile offset or clock never overflows.
pub fn local_civil(unix: u64, offset_secs: i64) -> (i64, u32, u32, (u32, u32, u32), i64) {
let local = i64::try_from(unix).unwrap_or(i64::MAX).saturating_add(offset_secs);
let days = local.div_euclid(86_400);
let t = local.rem_euclid(86_400) as u32;
let (y, m, d) = civil_from_days(days);
(y, m, d, (t / 3600, t / 60 % 60, t % 60), days)
}
/// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
+5 -2
View File
@@ -194,12 +194,15 @@ fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) ->
Ok(out)
}
/// Draw the chart of `o` again at its current size (after a resize), if it has a model.
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
/// saving writes that back with the look WordCraft doesn't model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
*graphic = Arc::new(chart_graphic(spec.clone(), theme, *w, *h));
let source = graphic.source.clone();
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
}
}
+231 -5
View File
@@ -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
@@ -65,14 +66,60 @@ fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option
update_generated(s)
}
/// Most paragraphs a list held as a field around its entries spans.
const MAX_WRAPPED: usize = 10_000;
/// A generated list (index, bibliography, table of figures or authorities) held as a complex
/// field that starts at body block `i` and runs over its entries: its field code and the block
/// it ends in. `None` for anything else (a TOC has its own update).
fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize, usize, usize)> {
let p = s.doc.body.get(i)?.as_para()?;
let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o {
InlineObject::FieldStart { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some())
.then(|| (k, off, t.to_string()))
}
_ => None,
})?;
// Only when it starts the paragraph: text before it is the document's, not the list's.
if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) {
return None;
}
// Where it ends: the field end that closes it, nested fields counted from its own start (a
// field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it).
let mut depth = 0usize;
for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) {
let Some(q) = b.as_para() else { continue };
for (at, o) in q.objects_with_offsets().skip(if j == i { k } else { 0 }) {
match o {
InlineObject::FieldStart { .. } => depth += 1,
InlineObject::FieldEnd => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some((instr, off, j, at + wordcraft_doc::para::OBJ.len_utf8()));
}
}
_ => {}
}
}
}
None
}
fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
p.objects.iter().find_map(list_field)
}
/// The field code of `o` when it is a generated list's field.
fn list_field(o: &InlineObject) -> Option<String> {
match o {
InlineObject::Field { instr, .. } => {
let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
}
_ => None,
})
}
}
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
@@ -152,14 +199,64 @@ pub(super) const ENTRY_STYLE: &str = "ListEntry";
pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
let mut i = 0;
while i < s.doc.body.len() {
let kind = s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind);
// A list held as a field around its entries (as Word and ODT files hold them): made the
// field's own paragraph, as WordCraft inserts one, its old entries rebuilt below.
// What follows its closing field end, and its first paragraph's properties and section
// break, are the document's and stay.
// (The field it is made: what comes before it in its paragraph may hold other fields.)
let mut converted = None;
if let Some((instr, start, last, end)) = wrapped_list(s, i) {
// The entries between its first and last paragraphs go.
for _ in i.saturating_add(1)..last {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
// Its first paragraph keeps what comes before the field (bookmarks, other fields)
// and the field; its last (or, ending in its first, the
// same) loses what is up to and through the field end, and goes when nothing is left
// of it and it ends no section.
let first_end = if last > i {
let q = s.doc.para_mut(StoryRef::Body, &Path::top(i + 1))?;
q.delete(0, end)?;
if q.is_empty() && q.section.is_none() {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
}
s.doc.para_mut(StoryRef::Body, &Path::top(i))?.len()
} else {
end
};
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
p.delete(start, first_end)?;
converted = Some(instr.trim_start().to_string());
p.insert_object(
start,
InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)?;
// What followed the field end in its own paragraph comes after the list, in a
// paragraph of its own that takes the section break.
let field = start + wordcraft_doc::para::OBJ.len_utf8();
if p.len() > field {
let rest = p.split_off(field)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(rest))?;
}
// Another list's field before it would be the paragraph's list on later updates (and
// have its shown list cleared with this one's): the list gets a paragraph of its own,
// after it, and each is rebuilt in its turn.
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
if p.objects_with_offsets().any(|(off, o)| off < start && list_field(o).is_some()) {
let list = p.split_off(start)?;
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1), Block::Para(list))?;
converted = None;
}
}
let kind = converted.or_else(|| s.doc.body.get(i).and_then(|b| b.as_para()).and_then(list_kind));
let Some(kind) = kind else {
i += 1;
continue;
};
add_entry_style(s);
if kind.starts_with("INDEX") {
crate::cmd::index::rebuild(s, i, None)?;
crate::cmd::index::rebuild_from(s, i, None, kind)?;
i += 1;
continue;
}
@@ -222,7 +319,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,
@@ -332,3 +429,132 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still
/// ends at its own field end, paragraphs below, not in its first paragraph.
#[test]
fn a_wrapped_list_ends_at_its_own_field_end() {
let mut d = Document::from_text("Old one\nOld two\nAfter");
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) {
let n = p.len();
p.insert_object(n, InlineObject::FieldEnd, &props).unwrap();
}
let s = Session::new(d);
assert_eq!(wrapped_list(&s, 0).map(|(instr, _, last, _)| (instr, last)), Some(("BIBLIOGRAPHY".to_string(), 1)));
}
/// A wrapped list as a session holds it: the field starting paragraph `first`'s text, ending
/// in paragraph `last` at byte `at`.
fn wrapped(text: &str, first: usize, last: usize, at: usize) -> Session {
let mut d = Document::from_text(text);
let props = CharProps::default();
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(last)) {
p.insert_object(at, InlineObject::FieldEnd, &props).unwrap();
}
if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(first)) {
p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap();
}
Session::new(d)
}
fn texts(s: &Session) -> Vec<String> {
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()
}
/// What follows a wrapped list's closing field end, in its last paragraph or in its only one,
/// stays, after the list; the last paragraph's section break stays too.
#[test]
fn what_follows_a_wrapped_lists_end_stays() {
// Ending in a later paragraph, with text after the end and a section break.
let mut s = wrapped("Old one\nOld two\nOld three. After\nBelow", 0, 2, "Old three.".len());
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(2)) {
p.section = Some(Box::default());
}
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.props.indent_left = Some(9.0);
}
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.iter().all(|t| !t.contains("Old")), "{t:?}");
assert!(t.iter().any(|t| t == " After"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
let after = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.plain_text() == " After");
assert!(after.is_some_and(|p| p.section.is_some()), "the section break went");
let first = s.doc.body.first().and_then(|b| b.as_para());
assert!(first.is_some_and(|p| p.props.indent_left == Some(9.0) && list_kind(p).is_some()), "{first:?}");
// An empty last paragraph without a section break goes.
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
update_generated(&mut s).unwrap();
// (The field's paragraph shows nothing: there are no sources to list.)
assert_eq!(texts(&s), ["", "Below"]);
// Ending in its own paragraph: what follows comes after the list.
let mut s = wrapped("Old entry. Then this\nBelow", 0, 0, "Old entry.".len());
update_generated(&mut s).unwrap();
let t = texts(&s);
assert!(t.first().is_some_and(|t| !t.contains("Old")), "{t:?}");
assert_eq!(t.get(1).map(String::as_str), Some(" Then this"), "{t:?}");
assert_eq!(t.last().map(String::as_str), Some("Below"));
}
/// What comes before a wrapped list's field in its first paragraph (a bookmark, an empty
/// `PAGE` field) stays there, before the rebuilt list's field.
#[test]
fn what_comes_before_a_wrapped_lists_field_stays() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
let props = CharProps::default();
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, InlineObject::FieldEnd, &props).unwrap();
p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "Sources".into() }, &props).unwrap();
}
update_generated(&mut s).unwrap();
let first = s.doc.body.first().and_then(|b| b.as_para());
let kinds: Vec<&str> = first
.map(|p| {
p.objects
.iter()
.map(|o| match o {
InlineObject::BookmarkStart { .. } => "bookmark",
InlineObject::FieldStart { instr, .. } if instr == "PAGE" => "page",
InlineObject::FieldEnd => "end",
InlineObject::Field { instr, .. } if instr.starts_with("BIBLIOGRAPHY") => "list",
_ => "other",
})
.collect()
})
.unwrap_or_default();
assert_eq!(kinds, ["bookmark", "page", "end", "list"]);
assert_eq!(texts(&s), ["", "Below"]);
}
/// A list field before a wrapped list's field in its paragraph doesn't take its place: the
/// wrapped list is the one rebuilt.
#[test]
fn a_wrapped_list_is_rebuilt_as_itself_after_another_list_field() {
let mut s = wrapped("Old one\nOld two\nBelow", 0, 1, "Old two".len());
s.doc.sources.push(wordcraft_doc::Source { tag: "a".into(), kind: "book".into(), title: "Listed Source".into(), ..Default::default() });
let index =
InlineObject::Field { instr: "INDEX".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(0)) {
p.insert_object(0, index, &CharProps::default()).unwrap();
}
// Updated again, each list stays itself.
for _ in 0..2 {
update_generated(&mut s).unwrap();
let t = texts(&s);
assert_eq!(t.iter().filter(|t| t.contains("Listed Source")).count(), 1, "the bibliography was rebuilt: {t:?}");
let lists: Vec<Option<String>> = s.doc.body.iter().filter_map(|b| b.as_para()).filter_map(list_kind).map(Some).collect();
assert_eq!(lists, [Some("INDEX".to_string()), Some("BIBLIOGRAPHY".to_string())]);
}
}
}
+7 -4
View File
@@ -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();
+11 -9
View File
@@ -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
View File
@@ -880,6 +880,7 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
};
*kind = new_kind;
*freeform = geometry;
extra.odf_geometry = None;
*fill = None;
let [_, _, raw_w, raw_h] = found.bounds();
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
+8 -5
View File
@@ -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),
@@ -345,7 +346,9 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
return Err(CmdError::Params(
"only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(),
));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
@@ -380,7 +383,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
+20 -1
View File
@@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge;
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
use wordcraft_doc::section::SectionProps;
use wordcraft_doc::table::{Cell, Row, Table};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, 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) {
+104 -1
View File
@@ -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::*;
+19 -1
View File
@@ -138,6 +138,17 @@ pub fn specs() -> Vec<CommandSpec> {
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
// Font › Emphasis mark: a mark over (or under) each character.
CommandSpec::new("format.emphasisMark", "Emphasis Mark", "Home › Font › Font", |s, v| {
use wordcraft_doc::props::EmphasisMark;
let name = p::str(v, "mark").unwrap_or("dot");
let mark = EmphasisMark::ALL
.into_iter()
.find(|m| m.ooxml().eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| CmdError::Params(format!("unknown emphasis mark `{name}` (none, dot, comma, circle, underDot)")))?;
apply(s, &|c| c.emphasis = Some(mark))
})
.params(r#"{"mark"?: "none" | "dot" | "comma" | "circle" | "underDot"}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
@@ -866,8 +877,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),
@@ -882,6 +899,7 @@ pub fn state(s: &Session) -> Value {
"caps": all.iter().all(|r| r.caps),
"hidden": all.iter().all(|r| r.hidden),
"spacing": same(&|r| json!(r.spacing)),
"emphasisMark": same(&|r| json!(r.emphasis.ooxml())),
// Kerning for fonts: the smallest kerned size, points; 0 = off.
"kern": same(&|r| json!(r.kern)),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
+40 -3
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::SectionStart;
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef};
use super::references::{FieldCode, 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");
@@ -264,6 +265,12 @@ pub fn rebuild(s: &mut Session, i: usize, leader: Option<TabLeader>) -> Result<(
}) else {
return Ok(());
};
rebuild_from(s, i, leader, instr)
}
/// [`rebuild`] the index at body block `i` from field code `instr` (that of the index field in
/// it, when the paragraph holds more than one).
pub fn rebuild_from(s: &mut Session, i: usize, leader: Option<TabLeader>, instr: String) -> Result<(), CmdError> {
let f = FieldCode::parse(&instr);
// Old entries go; keep their leader and the section their columns had.
let mut old_leader = None;
@@ -393,6 +400,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.
+2 -2
View File
@@ -15,7 +15,7 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_parts};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+71 -16
View File
@@ -61,14 +61,16 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
let iso = crate::cmd::now_iso();
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
let ss: u32 = iso.get(17..19).and_then(|x| x.parse().ok()).unwrap_or(0);
format_date_parts(fmt, y, m, d, (hh, mm, ss), (crate::cmd::now_unix() / 86_400) as i64)
// Word shows the computer's local date and time (#1707), not UTC's.
let now = crate::cmd::now_unix();
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts(fmt, y, m as usize, d, hms, days)
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
@@ -83,6 +85,28 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
// Text in single quotes is shown as it is; two quotes in a row are one, inside quoted
// text or out of it.
if c == '\'' && chars.get(i + 1) == Some(&'\'') {
out.push('\'');
i += 2;
continue;
}
if c == '\'' {
i += 1;
while let Some(&q) = chars.get(i) {
i += 1;
if q != '\'' {
out.push(q);
} else if chars.get(i) == Some(&'\'') {
out.push('\'');
i += 1;
} else {
break;
}
}
continue;
}
// `am/pm` (any case) is one token: AM or PM.
if chars.get(i..i + 5).is_some_and(|w| w.iter().collect::<String>().eq_ignore_ascii_case("am/pm")) {
out.push_str(if hh < 12 { "AM" } else { "PM" });
@@ -217,12 +241,16 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -230,6 +258,21 @@ pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
None
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
@@ -237,15 +280,27 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines_given = lines.is_some();
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
})
}
+12 -2
View File
@@ -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;
}
@@ -145,7 +146,16 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
h *= max_w / w;
w = max_w;
}
let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None };
let obj = InlineObject::Image {
media: key.clone(),
w,
h,
alt: alt.to_string(),
float: Float::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
};
// A selected Drawing Canvas takes it in.
if let Some(i) = crate::cmd::canvas::add_member(s, obj.clone())? {
return Ok(json!({"media": key, "canvasMember": i}));
+62
View File
@@ -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();
@@ -334,6 +349,18 @@ fn symbol_takes_its_own_font() {
assert!(s.run("insert.symbol", &json!({"char": ""})).is_err());
}
#[test]
fn date_pictures_keep_quoted_text() {
// Text in single quotes is shown as it is (Word's date-time picture); two quotes are one.
assert_eq!(format_date_parts("H' o''clock'", 2026, 10, 21, (8, 0, 0), 0), "8 o'clock");
assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5");
// An unclosed quote runs to the end.
assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so");
// Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text.
assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10");
assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10");
}
#[test]
fn date_pictures() {
// Word accepts either case for the day and year instructions.
@@ -354,3 +381,38 @@ fn date_pictures() {
assert_eq!(date_picture(r#"TIME \@ HH:mm"#).as_deref(), Some("HH:mm"));
assert_eq!(date_picture("DATE"), None);
}
/// #1707: Insert ▸ Date & Time and DATE/TIME fields use the local civil date, time and
/// weekday, not UTC's.
#[test]
fn format_date_uses_the_local_offset() {
// Noon on Saturday 31 October in New Zealand (UTC+13) is 2026-10-30T23:00:00Z.
assert_eq!(format_date_at("dddd, MMMM d, yyyy h:mm am/pm", 1_793_401_200, 13 * 3600), "Saturday, October 31, 2026 12:00 PM");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_498_400, -5 * 3600), "10/31/2026 21:00");
assert_eq!(format_date_at("M/d/yyyy H:mm", 1_793_401_200, 0), "10/30/2026 23:00");
}
#[test]
fn local_civil_date_follows_the_utc_offset() {
let pic = "dddd d MMMM yyyy HH:mm";
let show = |unix: u64, off: i64| {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, off);
format_date_parts(pic, y, m as usize, d, hms, days)
};
// 2026-10-30T23:00:00Z in New Zealand (UTC+13) is Saturday 31 October, noon.
assert_eq!(show(1_793_401_200, 13 * 3600), "Saturday 31 October 2026 12:00");
// 2026-11-01T02:00:00Z at UTC-5 is still Saturday 31 October, 21:00.
assert_eq!(show(1_793_498_400, -5 * 3600), "Saturday 31 October 2026 21:00");
// Offset 0 is UTC.
assert_eq!(show(1_793_401_200, 0), "Friday 30 October 2026 23:00");
// Near the epoch with a negative offset: the day before 1970, no overflow.
assert_eq!(show(0, -3600), "Wednesday 31 December 1969 23:00");
// Hostile values never panic.
let _ = crate::cmd::local_civil(u64::MAX, i64::MAX);
let _ = crate::cmd::local_civil(u64::MAX, i64::MIN);
let _ = crate::cmd::local_civil(0, i64::MIN);
// The machine's zone is a real offset (within ±26 h) and asking never panics.
for unix in [0, 1_793_401_200, u64::MAX] {
assert!(crate::cmd::local_offset_secs(unix).abs() <= 26 * 3600);
}
}
+7 -6
View File
@@ -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);
}
+8 -11
View File
@@ -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)?;
+8 -10
View File
@@ -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,
})
+23
View File
@@ -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 -1
View File
@@ -31,7 +31,7 @@ use picture::{clear_background, grown, picture_style};
pub(crate) use select::all_objects;
pub(crate) use select::{edit_obj, has_shape_or_canvas};
#[cfg(test)]
use select::{has_group, holds_ink, obj_size};
use select::{has_group, holds_ink, obj_size, with_obj};
pub use select::{min_size, object_selection, picked_objects, selected, selected_all};
#[cfg(test)]
use shape::can_link;
+48 -17
View File
@@ -1,5 +1,6 @@
use super::select::{obj_size, selected, with_obj};
use super::{CmdError, CmdResult, InlineObject, Rgb, Session, Value, Wrap, p};
use wordcraft_doc::picfx::PicFx;
/// `picture.crop`: the crop, and optionally the new display size, as one edit. With a size, a
/// floating picture positioned by offsets moves by what the crop on its left/top side on the page
@@ -148,13 +149,33 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
pub(super) fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
adjust_media(s, &media, f)
adjust_media(s, &media, |_| {}, f)
}
/// The selected picture's pre-edit media key (itself when never edited).
pub(super) fn original_media(s: &Session) -> Result<String, CmdError> {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
Ok(s.originals.get(&media).cloned().unwrap_or(media))
}
/// Drops brightness and contrast read from the file: an edit baked them from the original.
pub(super) fn clear_lum(fx: &mut PicFx) {
fx.bright = 0;
fx.contrast = 0;
}
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
/// Picture keeps working.
pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
/// `fx` changes the picture's brightness, contrast and transparency from the file (see
/// [`PicFx`]) for an edit that bakes its own.
pub(super) fn adjust_media(
s: &mut Session,
base: &str,
fx: impl Fn(&mut PicFx),
f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>,
) -> CmdResult {
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
@@ -179,8 +200,9 @@ pub(super) fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::Dynami
.unwrap_or_else(|| cur.clone().unwrap_or_else(|| key.clone()));
s.originals.entry(key.clone()).or_insert(orig);
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
if let InlineObject::Image { media, fx: pic_fx, .. } = o {
*media = key.clone();
fx(pic_fx);
}
})
}
@@ -244,19 +266,25 @@ pub(super) fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) ->
});
}
let l = look.clone();
let out = adjust_media(s, &look.base, move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
})?;
// Styles, borders and effects leave the file's brightness, contrast and transparency alone.
let out = adjust_media(
s,
&look.base,
|_| {},
move |img| {
let mut img = match &l.effect {
Some(e) => artistic_effect(img, e),
None => img,
};
if let Some(st) = &l.style {
img = picture_style(img, st)?;
}
if let Some((c, w)) = l.border {
img = frame(img, w, [c.0, c.1, c.2, 255])?;
}
Ok(img)
},
)?;
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
&& let Some(drawn) = s.doc.media.get(&media).cloned()
{
@@ -299,10 +327,13 @@ pub(super) fn reset(s: &mut Session, _: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let orig = s.originals.get(&media).cloned().unwrap_or(media);
// Like Word, Reset Picture discards every correction, those read from the file (`fx`:
// brightness, contrast, transparency, #1711, #1766) included.
with_obj(s, |o| {
if let InlineObject::Image { media, crop, .. } = o {
if let InlineObject::Image { media, crop, fx, .. } = o {
*media = orig.clone();
*crop = [0.0; 4];
*fx = PicFx::default();
}
})
}
+17 -14
View File
@@ -1,12 +1,13 @@
use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, crop, recolor, relook, remove_background, reset, size};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
use wordcraft_doc::picfx::PicFx;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -36,10 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
let sharpen = p::f32(v, "sharpen").unwrap_or(0.0).clamp(-100.0, 100.0);
// Absolute from the original (like transparency): re-setting all three to 0
// restores the original pixels instead of stacking on the last pass.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
// The file's own brightness and contrast (`fx`) are replaced, not stacked on.
let base = original_media(s)?;
adjust_media(s, &base, clear_lum, move |img| {
let mut img = img;
if b != 0.0 {
img = image::DynamicImage::ImageRgba8(image::imageops::colorops::brighten(&img, (b * 2.55) as i32));
@@ -62,10 +62,11 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = p::str(v, "mode").unwrap_or("grayscale").to_string();
let sat = p::f32(v, "saturation").unwrap_or(100.0);
// Absolute from the original (like transparency): saturation 100 restores it.
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
// Color and Corrections are independent, as in Word: only Washout, which Word stores
// as brightness and contrast (`a:lum`), replaces the file's own (`fx`).
let base = original_media(s)?;
let fx: fn(&mut PicFx) = if mode == "washout" { clear_lum } else { |_| {} };
adjust_media(s, &base, fx, move |img| Ok(recolor(img, &mode, sat)))
})
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
.when(has_picture),
@@ -97,10 +98,10 @@ pub fn specs() -> Vec<CommandSpec> {
// slider back and forth never fades what a previous pass already faded.
// (Combining with other pixel edits then re-setting transparency drops those
// edits; a full live-effect stack is future work.)
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
adjust_media(s, &base, move |img| {
// The file's own transparency (`fx`, `a:alphaModFix`) is replaced too: it's baked
// into the pixels from here on.
let base = original_media(s)?;
adjust_media(s, &base, |fx: &mut PicFx| fx.transparency = 0, move |img| {
let mut rgba = img.to_rgba8();
for px in rgba.pixels_mut() {
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
@@ -309,8 +310,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
with_obj(s, |o| {
if let InlineObject::Shape { kind: k, .. } = o {
if let InlineObject::Shape { kind: k, extra, .. } = o {
*k = kind;
// A geometry kept from a file is no longer this shape's.
extra.odf_geometry = None;
}
})
})
+103
View File
@@ -1,4 +1,5 @@
use super::*;
use wordcraft_doc::picfx::PicFx;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
@@ -454,6 +455,108 @@ fn transparency_is_absolute_from_the_original() {
assert_eq!(rgba(&s), orig, "reset still finds the original");
}
/// A picture opened with brightness, contrast and transparency from its file (`fx`, #1711,
/// #1766): Transparency and Corrections are absolute and replace the file's values instead of
/// stacking on them; Color leaves the file's brightness and contrast alone except Washout
/// (itself stored as brightness and contrast); Reset Picture discards every correction, the
/// file's included, as in Word.
#[test]
fn picture_edits_replace_the_file_corrections() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
let imported = PicFx::new(80_000, -80_000, 50_000);
let set_fx = |s: &mut Session, v: PicFx| {
with_obj(s, |o| {
if let InlineObject::Image { fx, .. } = o {
*fx = v;
}
})
.unwrap()
};
set_fx(&mut s, imported);
let state = |s: &Session| {
let (_, o) = selected(s).unwrap();
let InlineObject::Image { media, fx, .. } = o else { panic!("a picture") };
let bytes = s.doc.media.get(&media).cloned().unwrap();
(media, fx, image::load_from_memory(&bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect::<Vec<_>>())
};
let (orig_media, _, orig) = state(&s);
// Transparency 50%: 50% (not 75%), the file's transparency dropped, its lum kept.
s.run("picture.transparency", &json!({"percent": 50})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!(fx, PicFx::new(80_000, -80_000, 0));
assert!(px.iter().all(|p| p[3] == 127));
// 0%: opaque, no `a:alphaModFix` left to write.
s.run("picture.transparency", &json!({"percent": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx.transparency, px), (0, orig.clone()));
// Color other than Washout keeps the file's lum; Washout replaces it.
s.run("picture.color", &json!({"mode": "grayscale"})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "saturation", "saturation": 100})).unwrap();
assert_eq!(state(&s).1, PicFx::new(80_000, -80_000, 0));
s.run("picture.color", &json!({"mode": "washout"})).unwrap();
assert_eq!(state(&s).1, PicFx::default());
// Corrections bake from the original and replace the file's lum.
set_fx(&mut s, imported);
s.run("picture.corrections", &json!({"brightness": 0})).unwrap();
let (_, fx, px) = state(&s);
assert_eq!((fx, px), (PicFx::new(0, 0, 50_000), orig.clone()));
// Reset: the imported bitmap, no corrections at all.
set_fx(&mut s, imported);
s.run("picture.reset", &json!({})).unwrap();
let (media, fx, px) = state(&s);
assert_eq!((media, fx, px), (orig_media, PicFx::default(), orig));
}
/// Two pictures showing the same media, one with Washout from its file and one without:
/// edits on both bake to the same bytes, and resetting the plain one leaves it without
/// corrections (it used to be able to pick up the other's).
#[test]
fn reset_never_borrows_another_pictures_corrections() {
let mut d = wordcraft_doc::Document::new();
let media = d.add_media(png(20, 10), "png");
let washout = PicFx::new(70_000, -70_000, 0);
let mut p = wordcraft_doc::Paragraph::new();
for fx in [PicFx::default(), washout] {
let pic = InlineObject::Image {
media: media.clone(),
w: 40.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx,
ole: None,
};
p.insert_object(0, pic, &Default::default()).unwrap();
}
d.body = vec![wordcraft_doc::para_block(p)];
let mut s = Session::new(d);
let pick = |s: &mut Session, at: usize| {
s.run("select.range", &json!({"anchor": {"block": 0, "off": at}, "focus": {"block": 0, "off": at + 3}})).unwrap();
};
let fx_at = |s: &mut Session, at: usize| {
pick(s, at);
match selected(s).unwrap().1 {
InlineObject::Image { fx, media, .. } => (fx, media),
o => panic!("{o:?}"),
}
};
assert_eq!(fx_at(&mut s, 0).0, washout, "A");
assert_eq!(fx_at(&mut s, 3).0, PicFx::default(), "B");
for at in [0, 3] {
pick(&mut s, at);
s.run("picture.transparency", &json!({"percent": 30})).unwrap();
}
assert_eq!(fx_at(&mut s, 0).1, fx_at(&mut s, 3).1, "the same baked bytes");
pick(&mut s, 3);
s.run("picture.reset", &json!({})).unwrap();
assert_eq!(fx_at(&mut s, 3), (PicFx::default(), media.clone()));
assert_eq!(fx_at(&mut s, 0).0, washout, "A keeps its own until reset");
}
/// #2052: a picture style picked after another replaces it (Thick Frame then Simple Frame is
/// only the simple frame), a border replaces the border and keeps the style, an artistic
/// effect replaces the effect, and transparency 50% then 10% is 10%, as in Word.
+2 -1
View File
@@ -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)
+4 -3
View File
@@ -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 {
+2 -1
View File
@@ -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);
+6 -5
View File
@@ -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;
}
+2 -1
View File
@@ -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));
}
}
+2 -2
View File
@@ -136,7 +136,7 @@ fn read_only_exception_around_a_table_lets_it_be_formatted() {
#[test]
fn formatting_restriction_refuses_attach_template_update() {
let mut t = Document::from_text("Template.");
if let Some(st) = 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}");
+56 -30
View File
@@ -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() {

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