Compare commits

..
Author SHA1 Message Date
Martin Ball 9bd5ddf3d5 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
2026-11-08 03:15:16 +00:00
Martin Ball 6704146780 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 02:59:39 +00:00
Dylan M. Taylor 64711a718a merge main into fix/sec-xlsx-sheet-rels 2026-11-03 19:34:33 +00:00
Dylan M. Taylor 6e09267039 style: rustfmt 2026-11-03 16:30:40 +00:00
Dylan M. Taylor b218e191da fix(formats): index XLSX worksheet relationships by id 2026-11-03 11:46:15 +00:00
110 changed files with 245 additions and 4116 deletions
Generated
+1 -13
View File
@@ -1158,21 +1158,10 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -7109,7 +7098,6 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
+1 -50
View File
File diff suppressed because one or more lines are too long
+1 -3
View File
@@ -17,9 +17,7 @@ path = "src/main.rs"
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
-61
View File
@@ -1,61 +0,0 @@
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
//! when the app reaches it, as it does for a desktop client that timed out.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use wordcraft_ui_egui::ControlRequest;
/// The app id in `window.<app>`.
const APP: &str = "wordcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Value>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply,
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
-2
View File
@@ -20,8 +20,6 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
+1 -2
View File
@@ -76,8 +76,7 @@ pub fn start() {
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = WordApp::new(session, services).with_control(control);
let mut app = WordApp::new(session, services);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
-2
View File
@@ -69,8 +69,6 @@ impl eframe::App for App {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
-7
View File
@@ -14,10 +14,6 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.SenuraMalaka]
real_name = "Senura Dissanayake"
sync_github_name = true
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
@@ -201,6 +197,3 @@ display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
+2 -9
View File
@@ -772,10 +772,6 @@ impl Document {
if a.path.parent() == b.path.parent() {
// Same container: trim the ends, drop the blocks between, join.
let (ia, ib) = (a.path.last(), b.path.last());
// Removing an already-empty paragraph must not impose its style or mark formatting
// on the following content. Test before trimming: deleting actual heading text is
// still an ordinary merge that keeps the first paragraph's properties.
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
// Kept markers of the paragraphs in between move to the join.
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
@@ -823,18 +819,15 @@ impl Document {
if ib < bl.len() && ia < ib {
bl.drain(ia + 1..=ib);
}
tail.props = ParaProps::default();
// The deleted marks include `a`'s, so a section break there goes too: the joined
// paragraph ends where `b`'s mark did, and its text takes the following section's
// setup, as in Word (#2127).
let sect = tail.section.take();
let pa = self.para_mut(a.story, &a.path)?;
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
let keep_mark = empty_start.then(|| tail.mark.clone());
let keep_props = pa.props.clone();
pa.append(tail);
pa.props = keep_props;
if let Some(mark) = keep_mark {
pa.mark = mark;
}
let before = std::mem::replace(&mut pa.section, sect);
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
// Nearest first, as "link to previous" resolved them before the delete.
-10
View File
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -206,10 +200,6 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
-23
View File
@@ -186,11 +186,6 @@ pub struct Float {
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
#[serde(skip_serializing_if = "is_false")]
pub bare_turn: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -246,7 +241,6 @@ impl Float {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
self.bare_turn = false;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
@@ -256,23 +250,6 @@ impl Float {
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
pub fn is_bare_turn(&self) -> bool {
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
}
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
/// and from 225° up to 315°.
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
if !self.is_bare_turn() {
return self.spin_pad(w, h);
}
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let deg = wordcraft_geom::normalize_degrees(self.rot);
if modern && ((45.0..135.0).contains(&deg) || (225.0..315.0).contains(&deg)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
+1 -2
View File
@@ -27,8 +27,7 @@ pub use char_props::{
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
TextAlignment, TextFrame,
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
-65
View File
@@ -26,67 +26,6 @@ impl Align {
}
}
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
///
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParagraphTextDirection {
LrTb,
LrTbV,
Tb,
TbRl,
TbRlV,
TbLr,
TbLrV,
BtLr,
TbV,
Rl,
RlV,
Lr,
LrV,
}
impl ParagraphTextDirection {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"lrTb" => Some(Self::LrTb),
"lrTbV" => Some(Self::LrTbV),
"tb" => Some(Self::Tb),
"tbRl" => Some(Self::TbRl),
"tbRlV" => Some(Self::TbRlV),
"tbLr" => Some(Self::TbLr),
"tbLrV" => Some(Self::TbLrV),
"btLr" => Some(Self::BtLr),
"tbV" => Some(Self::TbV),
"rl" => Some(Self::Rl),
"rlV" => Some(Self::RlV),
"lr" => Some(Self::Lr),
"lrV" => Some(Self::LrV),
_ => None,
}
}
pub fn ooxml(self) -> &'static str {
match self {
Self::LrTb => "lrTb",
Self::LrTbV => "lrTbV",
Self::Tb => "tb",
Self::TbRl => "tbRl",
Self::TbRlV => "tbRlV",
Self::TbLr => "tbLr",
Self::TbLrV => "tbLrV",
Self::BtLr => "btLr",
Self::TbV => "tbV",
Self::Rl => "rl",
Self::RlV => "rlV",
Self::Lr => "lr",
Self::LrV => "lrV",
}
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -452,9 +391,6 @@ pub struct ParaProps {
pub suppress_line_numbers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bidi: Option<bool>,
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_direction: Option<ParagraphTextDirection>,
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
@@ -526,7 +462,6 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
+2 -31
View File
@@ -137,12 +137,9 @@ impl ResolvedChar {
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
}
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
/// 0.65 of the run's size rounded to the half point, a font size the format can store
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
pub fn draw_size(&self) -> f32 {
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
}
/// Baseline shift, points (positive = up).
pub fn baseline_shift(&self) -> f32 {
@@ -456,32 +453,6 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
mod tests {
use super::*;
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
/// its PDF export): 0.65 of the size, rounded to the half point.
#[test]
fn raised_and_lowered_text_is_sized_to_the_half_point() {
for (size, want) in [
(8.0, 5.0),
(9.0, 6.0),
(10.0, 6.5),
(11.0, 7.0),
(12.0, 8.0),
(13.0, 8.5),
(14.0, 9.0),
(16.0, 10.5),
(20.0, 13.0),
(24.0, 15.5),
(36.0, 23.5),
] {
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
}
}
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
}
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
+2 -7
View File
@@ -19,8 +19,8 @@
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol table for a single family name, by lowercase family name.
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
@@ -37,11 +37,6 @@ fn single_table(family: &str) -> Option<&'static [u32; 224]> {
}
}
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
-158
View File
@@ -1,158 +0,0 @@
use std::sync::Arc;
use wordcraft_doc::props::{Align, CharProps, ParaProps};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
fn fixture(first: &str, style: Option<&str>) -> Document {
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Heading1".into());
p.props.outline_level = Some(0);
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.props = ParaProps {
style: style.map(str::to_string),
align: Some(Align::Right),
indent_left: Some(18.0),
space_after: Some(7.0),
keep_next: Some(false),
..Default::default()
};
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
d
}
/// Compare paragraph, mark and inline-run formatting independently of text.
fn assert_format(p: &Paragraph, expected: &Paragraph) {
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
}
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
#[test]
fn deleting_an_empty_paragraph_keeps_the_following_format() {
for first_style in ["Heading1", "Heading3", "Normal"] {
for style in [None, Some("Normal"), Some("Heading2")] {
let mut d = fixture("", style);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, expected.text);
assert_format(p, &expected);
}
}
}
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
#[test]
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
for backwards in [false, true] {
let mut d = fixture("", None);
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
expected.delete(0, 5).unwrap();
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
assert_eq!(d.body.len(), 1);
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "paragraph.");
assert_format(p, &expected);
}
}
/// A control whose closing marker survives must move across the join without replacing tail formatting.
#[test]
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
use wordcraft_doc::control::ContentControl;
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
let mut d = fixture("", Some("Normal"));
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
p.mark.ins = Some(0);
p.mark.del = Some(1);
let expected = p.clone();
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props, expected.props);
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
assert_eq!(p.props_of_char(0), &marker);
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
let ranges = d.control_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, Pos::body(0, 0));
assert_eq!(ranges[0].end, Pos::body(1, 5));
assert!(!d.has_unbalanced_controls());
}
/// The empty-paragraph rule applies in header stories and table-cell containers.
#[test]
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
for cell in [false, true] {
let mut d = fixture("", Some("Normal"));
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let (story, a, b) = if cell {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = d.body.clone();
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
} else {
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
(StoryRef::Part(id), Path::top(0), Path::top(1))
};
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
assert_format(d.para(story, &a).unwrap(), &expected);
}
}
/// Absorbing an empty section keeps the following format and inherited header.
#[test]
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
let mut d = fixture("", None);
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
let mut section = d.last_section.clone();
section.headers.default = Some(header);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
d.last_section.margin_left = 96.0;
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
assert_eq!(d.last_section.headers.default, Some(header));
assert_eq!(d.last_section.margin_left, 96.0);
}
/// Deleting actual text continues to use the first paragraph merge rule.
#[test]
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
for off in [0, 7] {
let mut d = fixture("Heading", None);
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
}
/// Removing a soft line break must not change paragraph formatting.
#[test]
fn a_soft_break_is_not_an_empty_paragraph_merge() {
let mut d = fixture("", None);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
let expected = p.props.clone();
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
assert_eq!(d.body.len(), 2);
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
+1 -5
View File
@@ -211,11 +211,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
kind = ShapeKind::TextBox;
}
let line = kind == ShapeKind::Line;
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
// drawn as their box here, which a default fill would make an opaque block over the text.
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
// Line widths are EMUs; Word's default is 0.75 pt.
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
-3
View File
@@ -260,9 +260,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
};
let mut doc = Document::new();
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
// 15 would apply Word 2013+ justification and line-breaking rules to them.
doc.settings.compat_mode = 11;
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
}
-9
View File
@@ -337,15 +337,6 @@ fn empty_document_gets_a_paragraph() {
assert_eq!(doc.body.len(), 1);
}
#[test]
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
// line breaking.
assert_eq!(doc.settings.compat_mode, 11);
}
#[test]
fn header_text_stays_out_of_body() {
let f = text_doc(
-35
View File
@@ -183,41 +183,6 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
// white fill.
let brace = sp(&[fsp(88, 1026, 0xA00)]);
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
let rect = sp(&[fsp(1, 1028, 0xA00)]);
let art = art_content(&[], &[brace, bracket, rect]);
let flags = spa_flags(0, 0, 3, false);
let spas =
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let fills: Vec<_> = p0
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
fills,
[
(None, Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
]
);
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
+1 -20
View File
@@ -350,7 +350,6 @@ impl Package {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -406,13 +405,11 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub list: Vec<Rel>,
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
ids: HashMap<String, usize>,
}
impl Rels {
pub fn by_id(&self, id: &str) -> Option<&Rel> {
self.ids.get(id).and_then(|&i| self.list.get(i))
self.list.iter().find(|r| r.id == id)
}
pub fn by_type(&self, full: &str) -> Option<&Rel> {
self.list.iter().find(|r| rel_is(&r.kind, full))
@@ -550,22 +547,6 @@ mod tests {
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn relationships_are_looked_up_by_id_the_first_one_winning() {
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..20_000 {
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
}
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
let r = Package::open(&z).unwrap().rels("word/document.xml");
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
assert!(r.by_id("rId20000").is_none());
// Hyperlink-style lookups, one per relationship, are each a map lookup.
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
-3
View File
@@ -66,9 +66,6 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+2 -6
View File
@@ -91,11 +91,8 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -223,7 +219,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
-62
View File
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
+1 -24
View File
@@ -239,13 +239,7 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -394,23 +388,6 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
-2
View File
@@ -55,8 +55,6 @@ impl Reader<'_> {
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.flat_map(|n| n.split(';'))
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
+10 -41
View File
@@ -5,17 +5,16 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
TextDirection, Underline, VAlign, VMerge, VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
PageVAlign, SectionProps, SectionStart,
};
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -146,29 +145,6 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
}
}
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
/// drop an over-long name.
fn bounded_font_list(v: &str) -> Option<String> {
if v.len() <= MAX_FONT_NAME {
return (!v.is_empty()).then(|| v.to_string());
}
let mut end = 0usize;
for part in v.split_inclusive(';') {
let next = end + part.len();
if next > MAX_FONT_NAME {
break;
}
end = next;
}
let kept = v.get(..end)?.trim_end_matches(';');
(!kept.is_empty()).then(|| kept.to_string())
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
@@ -250,8 +226,6 @@ impl PropCtx {
shd_fill(e)
}
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
/// weight and posture of the complex-script and East Asian text beside them.
pub fn rpr(&self, e: &El) -> CharProps {
let mut c = CharProps::default();
for k in e.els() {
@@ -260,11 +234,7 @@ impl PropCtx {
"w:rFonts" => {
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
// A LibreOffice file names the Latin font as a semicolon-separated fallback
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
// corrupt or hostile, and every piece of text drawn in it would pay for its
// whole length as the list is classified (see `bounded_font_list`).
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
@@ -368,7 +338,6 @@ impl PropCtx {
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
@@ -407,10 +376,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw_u16(k, "w:before") {
if let Some(v) = tw(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw_u16(k, "w:after") {
if let Some(v) = tw(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -433,15 +402,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw_i16(k, "w:hanging") {
if let Some(v) = tw(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw_i16(k, "w:firstLine") {
} else if let Some(v) = tw(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
-5
View File
@@ -41,7 +41,6 @@ impl Reader<'_> {
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
if *e != 0.0 {
@@ -107,11 +106,7 @@ impl Reader<'_> {
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
if let Some(f) = obj.float_mut() {
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
let (px, py) = f.spin_pad(w, h);
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
// Zero adds no effects room when rotation contracts this axis.
-9
View File
@@ -224,15 +224,6 @@ impl Reader<'_> {
{
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
let tb_style = tb.attr("style").unwrap_or("");
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert;
}
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
+3 -4
View File
@@ -2,7 +2,6 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -318,12 +317,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw_i16(ind, "w:hanging") {
if let Some(h) = tw(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
} else if let Some(f) = tw(ind, "w:firstLine") {
lv.hanging = -f;
}
}
+3 -4
View File
@@ -83,14 +83,13 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
if empty_heading {
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
*body = if empty_heading { toc } else { engine };
*body = toc;
}
}
@@ -98,7 +97,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
-39
View File
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+7 -28
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = HashSet::new();
let mut used = Vec::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -499,9 +499,8 @@ impl super::Writer<'_> {
}
}
"wp:effectExtent" => {
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
// unless the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(w, h);
let [el, et, er, eb] = float.effect_extent();
let ext = [el + px, et + py, er + px, eb + py].map(emu);
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
@@ -638,15 +637,13 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.insert(v.clone());
out.push(v.clone());
}
}
for c in e.els() {
@@ -854,24 +851,6 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
-3
View File
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(direction) = p.text_direction {
w.val("w:textDirection", direction.ooxml());
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
+1 -18
View File
@@ -86,28 +86,11 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
})
};
// Bookmark, comment and permission markers after the end may stay there: a body-level
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
// and the control still covers the whole paragraph.
let at_end = |b: usize, off: usize| {
use wordcraft_doc::InlineObject as O;
para(b).is_some_and(|p| {
let from = off + OBJ.len_utf8();
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
matches!(
p.object_at(o),
Some(
O::ControlEnd
| O::BookmarkStart { .. }
| O::BookmarkEnd { .. }
| O::CommentStart { .. }
| O::CommentEnd { .. }
| O::PermStart { .. }
| O::PermEnd { .. }
)
)
})
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
})
};
let mut mode = vec![Mode::Inline; ranges.len()];
+2 -3
View File
@@ -298,9 +298,8 @@ impl Writer<'_> {
}
}
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
// the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(iw, ih);
let [el, et, er, eb] = float.effect_extent();
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
+2 -31
View File
@@ -462,22 +462,15 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
attach(&mut stack, &mut root, el);
}
}
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
// `&#13;` is a character reference and stays.
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
push_text(top, &s);
}
}
Event::CData(t) => {
if let Some(top) = stack.last_mut() {
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
let s = t
.xml_content(XmlVersion::Explicit1_0)
.map(|s| s.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
let s = String::from_utf8_lossy(&t).into_owned();
push_text(top, &s);
}
}
@@ -731,28 +724,6 @@ mod tests {
assert_eq!(t.attr("xml:space"), Some("preserve"));
}
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
#[test]
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
let x =
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
}
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; `&#13;` stays a CR.
#[test]
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc&#13;d<![CDATA[e\r\nf]]></x:t></x:document>";
let e = parse(x).unwrap();
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
// The writer escapes a CR, so a CR in the text survives a save.
let x = format!(
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
esc("a\r\nb\rc")
);
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
}
#[test]
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
-54
View File
@@ -342,57 +342,3 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let content = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2"))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
for pass in 0..2 {
let d = read(&bytes).unwrap();
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
// not gain a Table of Contents w:sdt on save.
let bare = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
p(&t("Chapter One")),
);
let d_bare = read(&package(&bare)).unwrap();
let xml_bare = document_xml(&write(&d_bare).unwrap());
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
let sect_toc = format!(
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let d_sect = read(&package(&format!("{}{}", sdt(pr, &sect_toc), p(&t("Chapter One"))))).unwrap();
assert!(controls(&d_sect).is_empty());
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
let xml_sect = document_xml(&write(&d_sect).unwrap());
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
}
-84
View File
@@ -3947,33 +3947,6 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
}
}
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
/// within the bound, so the family a real list would pick survives while a hostile list cannot
/// cost its whole length in every piece of text drawn in it.
#[test]
fn a_hostile_fallback_list_keeps_its_leading_families() {
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
let list = format!("{};Source Serif 4", candidates.join(";"));
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
let d = read_body(&body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
let kept = c.font.as_deref().expect("the leading families are kept");
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
}
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
#[test]
fn a_real_fallback_list_is_read_whole() {
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
}
/// The font table's family classes: Word draws a missing roman font in a serif face.
#[test]
fn roman_fonts_are_read_from_the_font_table() {
@@ -3983,15 +3956,6 @@ fn roman_fonts_are_read_from_the_font_table() {
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
}
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
#[test]
fn roman_fonts_split_fallback_lists() {
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
}
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
#[test]
fn html_auto_spacing_off_is_read_and_kept() {
@@ -4390,54 +4354,6 @@ fn zero_effects_stay_zero_rotated() {
}
}
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
/// its rotated bounds.
#[test]
fn bare_turns_are_kept_through_a_save() {
let pic = |effects: &str| {
format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
)
};
let read =
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
let d = read(&pic(effects));
assert!(bare(&d), "{effects:?}");
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
}
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
assert!(!bare(&d));
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
assert!(!f.bare_turn, "turned here: the rotated bounds again");
// A shadow's room added later: saved, and so laid out, with the turn's room again.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.effect = [2.0; 4];
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.wrap = wordcraft_doc::para::Wrap::Square;
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
assert!(!bare(&read(&anchored)));
}
/// The math namespace, declared on the math element itself (the fixture root doesn't).
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
-2
View File
@@ -21,7 +21,6 @@ mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod nested_block_controls;
mod note_comments;
mod passthrough;
mod picture_links;
@@ -40,7 +39,6 @@ mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
@@ -1,180 +0,0 @@
//! A body-level content control nested directly in another body-level one stays body-level when
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{ContentControl, Document, StoryRef};
use wordcraft_docx::{read, write};
const NS: &str =
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><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_RELS.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(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn controls(d: &Document) -> Vec<ContentControl> {
let mut out = Vec::new();
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
out
}
/// How many `w:p` elements are open at byte `at` of `xml`.
fn open_paragraphs(xml: &str, at: usize) -> usize {
let head = &xml[..at];
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
opened - head.matches("</w:p>").count()
}
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
fn depth_of_control(xml: &str, marker: &str) -> usize {
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
let start = xml[..at].rfind("<w:sdt>").expect("control start");
open_paragraphs(xml, start)
}
fn round_trip(body: &str) -> (Document, Document, String) {
let d = read(&package(body)).unwrap();
let bytes = write(&d).unwrap();
let back = read(&bytes).unwrap();
(d, back, document_xml(&bytes))
}
fn repeating_section() -> String {
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
}
#[test]
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
let (_, _, xml) = round_trip(&repeating_section());
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
// The text control stays inside the paragraph, and the item holds that paragraph.
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
}
#[test]
fn a_repeating_section_round_trips_its_controls_and_text() {
let (d, back, _) = round_trip(&repeating_section());
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
assert_eq!(back.body.len(), d.body.len());
}
#[test]
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
}
#[test]
fn nested_block_controls_in_a_table_cell_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
}
#[test]
fn a_run_level_control_stays_inside_its_paragraph() {
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
}
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
/// the body-level `_GoBack` bookmark Word leaves after the last control.
fn bound_section_body(bound: bool, bookmark: bool) -> String {
let section_binding = if bound {
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
} else {
""
};
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
let field = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
if bound { bind("/a[1]/n[1]") } else { String::new() },
t("note 1")
);
let item = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
p(&format!("{field}{}", t(" ")))
);
let section = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
);
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
}
fn assert_section_block_level(body: &str) {
let (d, back, xml) = round_trip(body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
}
#[test]
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(true, true));
}
#[test]
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
assert_section_block_level(&bound_section_body(true, false));
}
#[test]
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(false, true));
}
#[test]
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
let (d, back, xml) = round_trip(&body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
// The bookmark is kept.
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
}
+2 -3
View File
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -195,7 +195,6 @@ fn every_para_prop_round_trips() {
suppress_hyphens: Some(true),
suppress_line_numbers: Some(true),
bidi: Some(false),
text_direction: Some(ParagraphTextDirection::TbRl),
drop_cap: None,
drop_cap_margin: None,
drop_cap_distance: None,
@@ -18,25 +18,6 @@ fn docx(body: &str) -> Vec<u8> {
zw.finish().unwrap().into_inner()
}
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
for (name, value) in [
("_rels/.rels", root.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/styles.xml", styles.as_bytes()),
] {
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
bytes.write_all(value).unwrap();
}
bytes.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();
@@ -78,52 +59,3 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
}
#[test]
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::props::ParagraphTextDirection;
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
let mut body: String =
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
for (para, value) in paras.iter().zip(values) {
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
}
assert_eq!(paras[values.len()].props.text_direction, None);
let json = serde_json::to_value(&paras[0].props).unwrap();
assert_eq!(json["textDirection"], "lrTb");
let decoded: ParaProps = serde_json::from_value(json).unwrap();
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
let first = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&first);
for value in values {
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
}
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
let twice = document_xml(&second);
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
for value in values {
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
}
}
#[test]
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut styles_xml = String::new();
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
}
+1 -12
View File
@@ -132,15 +132,6 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
// Text direction (#5193), as Word for Windows reads it: `vertical` and
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
shape("", r#"style="layout-flow:vertical""#),
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
shape("", r#"style="layout-flow:vertical-ideographic""#),
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal-ideographic""#),
]
.concat();
let doc = format!(
@@ -149,15 +140,13 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 12);
assert_eq!(bodies.len(), 6);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
-55
View File
@@ -1,55 +0,0 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
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>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
-9
View File
@@ -270,12 +270,3 @@ fn flat_opc_limits_count_what_is_not_kept() {
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
}
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
#[test]
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
let d = read_xml(xml.as_bytes()).unwrap();
assert_eq!(paras(&d)[0].plain_text(), "a b");
}
+5 -17
View File
@@ -132,26 +132,14 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
.map(|p| (p.width, p.height))
.map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, w, h, crop, .. } = o {
if let InlineObject::Image { media, .. } = o {
*media = key.clone();
// The replacement keeps the picture's width and takes its own aspect ratio, so a
// picture of another shape isn't stretched into the old frame. The old crop
// described the old picture, so it is dropped.
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
let nh = *w * px_h as f32 / px_w as f32;
if nh.is_finite() && nh > 0.0 {
*h = nh;
}
}
*crop = [0.0; 4];
}
})
})
-37
View File
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
+4 -13
View File
@@ -102,9 +102,7 @@ struct SeqCount {
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
@@ -122,11 +120,7 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
let mut deleted = p.deleted_ranges().into_iter().peekable();
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
while deleted.next_if(|r| r.end <= off).is_some() {}
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
for (k, o) in p.objects.iter().enumerate() {
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
@@ -137,9 +131,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
}
}
}
let InlineObject::Field { instr, locked, .. } = o else { continue };
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" || in_deletion {
if f.name != "SEQ" {
continue;
}
let Some(id) = f.args.first() else { continue };
@@ -155,9 +149,6 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
if *locked {
continue;
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
-39
View File
@@ -793,45 +793,6 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
assert_eq!(results, expected);
}
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
#[test]
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
for instr in fields_in {
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let top = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
for (k, o) in para.objects.iter_mut().enumerate() {
if let InlineObject::Field { result, locked, .. } = o {
*locked = matches!(k, 4 | 6);
*result = if *locked { "7" } else { "9" }.into();
}
}
para.touch();
// The second Del field is a tracked deletion.
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
let out = output_fields(&s.doc).expect("the stale results change");
let shown: Vec<String> = out
.para(StoryRef::Body, &top)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
+5 -14
View File
@@ -54,20 +54,11 @@ fn resolve_para(
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
// mark carries a section break goes whole too: its section joins the following one, as when
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
if (del.is_some() && accept) || (ins.is_some() && !accept) {
let sectioned = story == StoryRef::Body
&& path.0.len() == 1
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
&& crate::cmd::has_next_para(s, story, path);
if sectioned {
s.doc.remove_section_break(path.last());
}
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
return Ok(());
}
// of a story or cell can't go, so it just stops being a revision.
if ((del.is_some() && accept) || (ins.is_some() && !accept))
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
{
return Ok(());
}
let para = s.doc.para_mut(story, path)?;
para.mark.ins = None;
+2 -2
View File
@@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
#[cfg(not(target_arch = "wasm32"))]
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > max_file {
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > max_file {
return None;
}
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
+2 -2
View File
@@ -390,7 +390,7 @@ impl Session {
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
self.view.follow_prefs(&self.prefs);
self.view.read_page_color = self.prefs.read_page_color;
self.others.push(d);
Ok(())
}
@@ -471,7 +471,7 @@ impl Session {
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
view.follow_prefs(&self.prefs);
view.read_page_color = self.prefs.read_page_color;
let mut d = DocState::new(doc, untitled, view);
let id = d.document_id;
self.order.push(id);
+1 -1
View File
@@ -32,7 +32,7 @@ impl Session {
self.hf_return = None;
self.go_back_step = 0;
self.view.outline.collapsed.clear();
self.view.follow_prefs(&self.prefs);
self.view.read_page_color = self.prefs.read_page_color;
// A copy the replaced document kept stays in the recovery list.
#[cfg(not(target_arch = "wasm32"))]
{
+1 -1
View File
@@ -135,7 +135,7 @@ impl Session {
let current = document_id == self.document_id && rev == self.rev;
self.sel = sel;
self.view = view;
self.view.follow_prefs(&self.prefs);
self.view.read_page_color = self.prefs.read_page_color;
self.pending = pending;
self.goal_x = goal_x;
self.page_hint = page_hint;
-116
View File
@@ -5747,39 +5747,6 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
assert!(device.unwrap_err().contains("not a file"));
}
#[cfg(unix)]
#[test]
fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() {
let dir = scratch_dir("template-title-fifo");
let fifo = dir.join("pipe.dotx");
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
if !made.is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let path = fifo.clone();
std::thread::spawn(move || {
let title = crate::io::package_title(&path, 64 << 20, 64 << 10);
let _ = tx.send(title);
});
let result = rx.recv_timeout(std::time::Duration::from_secs(10));
if result.is_err() {
// Unblock a stuck reader so the test thread can finish.
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(result.expect("reading a template title blocked on a named pipe"), None);
}
#[cfg(unix)]
#[test]
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
@@ -10817,44 +10784,6 @@ fn picture_change_keeps_the_replacement_format() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
/// a revised one of a different shape must not stretch it into the old frame.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn picture_change_keeps_width_and_takes_new_aspect() {
let dir = scratch_dir("picture-change-aspect");
let mut wide = Vec::new();
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
let mut tall = Vec::new();
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
std::fs::write(&first, &wide).unwrap();
std::fs::write(&second, &tall).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
let size = |s: &Session| {
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
s.doc.objects_in_reading_order(&mut |o| {
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
out = (*w, *h, *crop);
}
});
out
};
let (w0, h0, _) = size(&s);
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
run(&mut s, "picture.crop", json!({"left": 0.1}));
let (w_cropped, _, crop) = size(&s);
assert!(crop[0] > 0.0, "cropped before the change");
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
let (w1, h1, crop) = size(&s);
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_open_repairs_hostile_run_lengths() {
use wordcraft_doc::{Block, Document};
@@ -12858,48 +12787,3 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
fn sectioned_revision(delete: bool) -> Session {
use wordcraft_doc::{Revision, RevisionKind};
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody"));
run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}}));
run(&mut s, "para.style", json!({"style": "Heading1"}));
let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert };
s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None });
s.doc.last_section.margin_left = 72.0;
let mut sect = s.doc.last_section.clone();
sect.margin_left = 144.0;
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
let len = p.len();
p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap();
if delete {
p.mark.del = Some(0)
} else {
p.mark.ins = Some(0)
}
p.props.indent_left = Some(36.0);
p.section = Some(Box::new(sect));
s
}
#[test]
fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() {
// #5667: the resolved paragraph was left empty but kept for its section break, so the next
// paragraph joined it and took its properties (Normal, its indent). It goes whole instead,
// as an empty paragraph without a break does, and its section joins the following one.
for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] {
let mut s = sectioned_revision(delete);
assert_eq!(s.doc.sections().len(), 2);
run(&mut s, cmd, json!({}));
assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}");
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style");
assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it");
assert_eq!(s.doc.sections().len(), 1, "{cmd}");
assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays");
}
}
-48
View File
@@ -346,51 +346,3 @@ fn input_method_joins_stay_with_their_window() {
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
drop(a);
}
/// Check spelling as you type is one setting for the app, not one per open document (#5740):
/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off
/// in the others too, and switching to another document doesn't turn it back on, so what is saved
/// for the next run is what was chosen, whichever document is active then.
#[test]
fn check_spelling_is_one_setting_for_every_open_document() {
let mut s = s();
let a = s.document_id();
run(&mut s, "file.new", json!({}));
let b = s.document_id();
run(&mut s, "file.new", json!({}));
let c = s.document_id();
assert!(s.view.proofing);
s.activate(a).unwrap();
run(&mut s, "file.options", json!({"checkSpelling": false}));
for id in [b, c, a] {
s.activate(id).unwrap();
assert!(!s.view.proofing, "off in document {id}");
assert!(!s.prefs.check_spelling);
}
run(&mut s, "review.proofing", json!({"value": true}));
for id in [b, c] {
s.activate(id).unwrap();
assert!(s.view.proofing, "back on in document {id}");
}
// A document opened now starts with the setting as it is.
run(&mut s, "review.proofing", json!({"value": false}));
run(&mut s, "file.new", json!({}));
assert!(!s.view.proofing);
}
/// A window's view put back after Check spelling as you type changed in another window takes the
/// setting as it is now, not as it was when the window's view was taken (#5740): every view made
/// active follows the saved preference.
#[test]
fn a_window_view_put_back_follows_the_spelling_setting() {
let mut s = s();
assert!(s.view.proofing);
let held = s.take_view();
run(&mut s, "file.options", json!({"checkSpelling": false}));
s.put_view(held);
assert!(!s.view.proofing, "the view taken with spelling on comes back with it off");
let held = s.take_view();
run(&mut s, "review.proofing", json!({"value": true}));
s.put_view(held);
assert!(s.view.proofing);
}
-40
View File
@@ -469,43 +469,3 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
assert_eq!(m[0].shown_as, [shown], "{m:?}");
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
}
/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any
/// candidate is installed, and substitutes by candidate family when none is.
#[test]
fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() {
let db = wordcraft_fonts::FontDb::global();
let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]);
if db.has_family("Liberation Sans") || db.has_family("Arial") {
let m = missing_in(doc.clone());
assert!(m.is_empty(), "expected not to be reported as missing: {m:?}");
}
let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]);
assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed");
// Roundtrip through DOCX export and import:
let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap();
let loaded = wordcraft_docx::read(&docx_bytes).unwrap();
assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial"));
if db.has_family("Liberation Sans") || db.has_family("Arial") {
let m = missing_in(loaded);
assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}");
}
// When none of the fonts is installed, missing_fonts flags it with an appropriate substitute:
if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") {
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")]));
assert_eq!(m.len(), 1);
assert_eq!(m[0].font, "NonExistentSans123;Times New Roman");
let drawn = m[0].shown_as.first().expect("drawn face");
assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}");
}
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")]));
assert_eq!(m.len(), 1);
assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123");
let drawn = m[0].shown_as.first().expect("drawn face");
assert!(
drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia",
"drawn: {drawn}"
);
}
-13
View File
@@ -9,19 +9,6 @@ use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading.
/// Deleting the empty heading mark keeps the next paragraph as body text.
#[test]
fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() {
let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph."));
run(&mut s, "para.style", json!({"style": "Heading1"}));
run(&mut s, "text.delete", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "Body paragraph.");
assert_eq!(p.props.style, None);
assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]);
}
/// Create an empty session for command-driven typing fixtures.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
-11
View File
@@ -161,17 +161,6 @@ fn default_read_zoom() -> f32 {
1.0
}
impl ViewState {
/// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's
/// page colour and Check spelling as you type (#5740). A view made active goes through this
/// (a document switched to, opened or made, a window's view put back), so a change made while
/// another view was active holds in this one too.
pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) {
self.read_page_color = prefs.read_page_color;
self.proofing = prefs.check_spelling;
}
}
impl Default for ViewState {
fn default() -> Self {
ViewState {
@@ -1,80 +0,0 @@
use serde_json::{Value, json};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use wordcraft_engine::{Selection, Session};
use wordcraft_layout::outline;
/// Dispatch an engine command and report its ID if it fails.
fn run(s: &mut Session, id: &str, params: Value) {
s.run(id, &params).unwrap_or_else(|e| panic!("{id}: {e}"));
}
/// Create an empty heading, mixed-run body text and a genuine TOC heading.
fn fixture() -> Session {
let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n"));
run(&mut s, "para.style", json!({"style": "Heading1"}));
s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark =
CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() };
let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() };
p.format(5, 14, &|c| c.italic = Some(true)).unwrap();
s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into());
s
}
/// Keyboard and selection deletes preserve body formatting through Undo and Redo.
#[test]
fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() {
for mode in ["delete", "backspace", "selection", "reverse selection"] {
for pending in [false, true] {
let mut s = fixture();
let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone();
s.sel = match mode {
"backspace" => Selection::caret(Pos::body(1, 0)),
"selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) },
"reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) },
_ => Selection::caret(Pos::body(0, 0)),
};
if pending {
s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() });
}
let before = serde_json::to_value(&s.doc.body).unwrap();
run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, expected.text, "{mode}, pending={pending}");
assert_eq!(p.props, expected.props);
assert_eq!(p.mark, expected.mark);
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0));
assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic));
assert!(s.pending.is_none());
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
let after = serde_json::to_value(&s.doc.body).unwrap();
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
}
}
}
/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline.
#[test]
fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() {
let mut s = fixture();
run(&mut s, "text.delete", json!({}));
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap());
assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]);
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props.style.as_deref(), None);
assert_eq!(p.mark.size, Some(11.0));
assert_eq!(p.props_of_char(6).italic, Some(true));
s.sel = Selection::caret(Pos::body(2, 0));
run(&mut s, "references.toc", json!({"levels": 3}));
let text = s.doc.plain_text(StoryRef::Body);
assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}");
assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}");
}
+21 -377
View File
@@ -32,27 +32,12 @@ fn substitutes(family: &str) -> &'static [&'static str] {
/// as Liberation Serif for Times New Roman, or the same design under another name), rather than
/// the generic lists any unknown font gets?
pub fn has_named_substitutes(family: &str) -> bool {
if family.contains(';') {
return list_class(family).named;
}
substitutes_by_name(charset_base_family(family)).1
}
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
/// [`substitutes`], and whether they were chosen for this family by name.
fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
if family.contains(';') {
for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) {
let (subs, named) = substitutes_by_name(cand);
if named || is_thai_family(cand) || subs != SANS {
return (subs, named);
}
}
if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) {
return substitutes_by_name(first);
}
}
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
@@ -172,11 +157,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
/// PDFs give the fonts (`/Ascent`, `/Descent`).
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() {
match family.trim().to_ascii_lowercase().as_str() {
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
"wingdings" => Some((0.899, 0.205)),
_ => None,
})
}
}
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
@@ -239,10 +224,7 @@ fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
/// wraps and paginates as in `family`.
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
let s = substitute.trim();
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
let base = charset_base_family(f);
base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s))
})
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
}
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
@@ -374,63 +356,10 @@ fn is_thai_serif(f: &str) -> bool {
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
/// whole run, never in a Latin substitute (#1549).
pub fn is_thai_family(family: &str) -> bool {
if family.contains(';') {
return list_class(family).thai;
}
let f = family.trim().to_ascii_lowercase();
let f = family.to_ascii_lowercase();
is_thai(&f) || is_thai_serif(&f)
}
/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks
/// them for every piece of every run set in the list, so the answer is worked out once per list
/// rather than once per piece.
#[derive(Clone, Copy, Default)]
struct ListClass {
/// [`is_thai_family`].
thai: bool,
/// [`has_named_substitutes`].
named: bool,
}
/// [`ListClass`] of the semicolon-separated `family`, memoised by its text.
fn list_class(family: &str) -> ListClass {
let key = family_list_key(family);
let generation = FontDb::global().generation();
{
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation != generation {
*c = FallbackListMemo { generation, ..Default::default() };
} else if let Some((cached, class)) = c.classes.get(&key)
&& cached == family
{
return *class;
}
}
let class = ListClass { thai: classify_thai(family), named: classify_named(family) };
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation == generation {
if c.classes.len() >= FAMILY_CACHE_MAX {
c.classes.clear();
}
c.classes.insert(key, (family.to_string(), class));
}
class
}
/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers.
fn classify_thai(family: &str) -> bool {
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
let f = f.to_ascii_lowercase();
is_thai(&f) || is_thai_serif(&f)
})
}
/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`]
/// answers.
fn classify_named(family: &str) -> bool {
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1)
}
/// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's
/// own and Noto's. Matched by name only.
fn is_khmer(f: &str) -> bool {
@@ -487,168 +416,8 @@ fn resolve_cache() -> &'static Mutex<Memo> {
}
const FAMILY_CACHE_MAX: usize = 4096;
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
/// Family names longer than this (bytes) are resolved but never remembered.
const FAMILY_KEY_MAX: usize = 256;
/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list
/// for candidate names not present in [`FontDb::families`].
const MAX_DIRECT_CANDIDATE_PROBES: usize = 32;
/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`].
#[derive(Default)]
struct FallbackListMemo {
generation: u64,
families: std::collections::HashSet<String>,
probed: HashMap<String, bool>,
lists: HashMap<(u64, usize), (String, (String, bool))>,
long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>,
classes: HashMap<(u64, usize), (String, ListClass)>,
}
/// Shared cache for fallback-list resolutions and candidate availability checks.
fn fallback_list_cache() -> &'static Mutex<FallbackListMemo> {
static CACHE: OnceLock<Mutex<FallbackListMemo>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default()))
}
/// Hash and byte length of `family`, used as a compact cache key for fallback lists.
fn family_list_key(family: &str) -> (u64, usize) {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
family.hash(&mut h);
(h.finish(), family.len())
}
/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded
/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate.
fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool {
let cand = cand.trim();
if cand.is_empty() || cand.len() > FAMILY_KEY_MAX {
return false;
}
let generation = db.generation();
let lower = cand.to_ascii_lowercase();
{
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation != generation {
*c = FallbackListMemo { generation, ..Default::default() };
}
if c.families.is_empty() {
c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect();
}
if c.families.contains(&lower) {
return true;
}
if let Some(&hit) = c.probed.get(&lower) {
return hit;
}
}
if *direct_probes == 0 {
return false;
}
*direct_probes -= 1;
let hit = db.has_family(cand);
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation == generation {
if c.probed.len() >= FAMILY_CACHE_MAX {
c.probed.clear();
}
c.probed.insert(lower, hit);
}
hit
}
/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list.
fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) {
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation == generation {
if c.lists.len() >= FAMILY_CACHE_MAX {
c.lists.clear();
}
c.lists.insert(key, (family.to_string(), result.clone()));
}
result
}
/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`.
fn resolve_family(db: &FontDb, family: &str) -> (String, bool) {
if !family.contains(';') {
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
return if db.has_family(family) {
(family.to_string(), false)
} else if base != family && db.has_family(base) {
(base.to_string(), false)
} else {
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
};
}
let generation = db.generation();
let list_key = family_list_key(family);
{
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation != generation {
*c = FallbackListMemo { generation, ..Default::default() };
} else if let Some((cached_family, r)) = c.lists.get(&list_key)
&& cached_family == family
{
return r.clone();
}
}
let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect();
if candidates.is_empty() {
return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true));
}
let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES;
// 1. First installed family in the list:
for &cand in &candidates {
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
if candidate_has_family(db, cand, &mut direct_probes) {
return remember_list_resolution(generation, list_key, family, (cand.to_string(), false));
}
if base != cand && candidate_has_family(db, base, &mut direct_probes) {
return remember_list_resolution(generation, list_key, family, (base.to_string(), false));
}
}
// 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito.
// Try Thai substitutes before metric twins of later candidates:
for &cand in &candidates {
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
if is_thai_family(base)
&& let Some(sub) = substitute(db, base)
{
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
}
let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES;
if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) {
return remember_list_resolution(generation, list_key, family, (twin.to_string(), true));
}
}
// Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono):
for &cand in &candidates {
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
let (subs, named) = substitutes_by_name(base);
if (named || is_thai_family(base) || subs != SANS)
&& let Some(sub) = substitute(db, base)
{
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
}
}
// Fall back to the first candidate's substitute:
if let Some(&first) = candidates.first() {
let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) };
if let Some(sub) = substitute(db, base) {
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
}
}
remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true))
}
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
/// [`FontDb::generation`]).
@@ -658,10 +427,6 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
let generation = db.generation();
let cacheable = family.len() <= FAMILY_KEY_MAX;
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
let long_key = (!cacheable && family.contains(';')).then(|| {
let (h, len) = family_list_key(family);
(h, len, bold, italic)
});
{
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.0 != generation {
@@ -671,35 +436,24 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
return *r;
}
}
if let Some(lk) = long_key {
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation != generation {
*c = FallbackListMemo { generation, ..Default::default() };
} else if let Some((cached_family, r)) = c.long_resolved.get(&lk)
&& cached_family == family
{
return *r;
}
}
let (fam, substituted) = resolve_family(db, family);
// Printer-driver Symbol aliases use the same encoding as Symbol.
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
let (fam, substituted) = if db.has_family(family) {
(family.to_string(), false)
} else if base != family && db.has_family(base) {
// Windows maps the charset names to the font itself: not a substitution.
(base.to_string(), false)
} else {
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
};
let face = db.face(&fam, style_name(bold, italic));
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
if cacheable {
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.0 == generation {
if c.1.len() >= FAMILY_CACHE_MAX {
c.1.clear();
}
c.1.insert(key, r);
}
} else if let Some(lk) = long_key {
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.generation == generation {
if c.long_resolved.len() >= FAMILY_CACHE_MAX {
c.long_resolved.clear();
}
c.long_resolved.insert(lk, (family.to_string(), r));
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cacheable && c.0 == generation {
if c.1.len() >= FAMILY_CACHE_MAX {
c.1.clear();
}
c.1.insert(key, r);
}
r
}
@@ -1033,114 +787,4 @@ mod tests {
assert!(!has_named_substitutes(f), "{f}");
}
}
/// Semicolon-separated fallback lists resolve to the first installed family, or to a
/// candidate's substitute when no listed family is installed.
#[test]
fn libreoffice_fallback_list_resolves_first_installed_family() {
let db = FontDb::global();
if db.has_family("Liberation Sans") || db.has_family("Arial") {
let r = resolve("Liberation Sans;Arial", false, false);
assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family);
if db.has_family("Liberation Sans") {
assert_eq!(r.face.family, "Liberation Sans");
} else {
assert_eq!(r.face.family, "Arial");
}
}
let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false);
assert!(!bundled.substituted);
assert_eq!(bundled.face.family, "Source Serif 4");
// When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito:
if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") {
let r = resolve("NonExistentSansFace123;Times New Roman", false, false);
assert!(r.substituted);
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman");
assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family);
}
let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false);
assert!(r.substituted);
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family");
assert!(
r.face.family.contains("Serif")
|| r.face.family == "Times"
|| r.face.family == "Times New Roman"
|| r.face.family == "Tinos"
|| r.face.family == "Caladea"
|| r.face.family == "Georgia",
"got {:?}",
r.face.family
);
// Metric compatibility of fallback list:
assert!(metric_compatible("Open Sans;Arial", "Liberation Sans"));
assert!(metric_compatible("Liberation Sans;Arial", "Helvetica"));
assert!(!metric_compatible("Open Sans;Arial", "Carlito"));
}
/// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an
/// earlier Thai substitute takes precedence over a later candidate's metric twin.
#[test]
fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() {
let db = FontDb::global();
let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else {
eprintln!("skipped: no Thai substitute installed");
return;
};
if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) {
let r = resolve("Cordia New;Arial", false, false);
assert!(r.substituted);
assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family);
}
if let Some(latin) =
METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t)))
{
let list = format!("NonExistentThaiUPC;{latin}");
let r = resolve(&list, false, false);
assert!(r.substituted);
assert_eq!(r.face.family, thai_sub);
}
}
/// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and
/// cache their resolution without growing `resolve_cache` string keys.
#[test]
fn long_fallback_list_selects_later_installed_family_and_caches() {
let mut candidates: Vec<String> = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect();
candidates.push("Source Serif 4".to_string());
let list = candidates.join(";");
assert!(list.len() > FAMILY_KEY_MAX);
let r1 = resolve(&list, false, false);
let r2 = resolve(&list, false, false);
assert!(!r1.substituted);
assert_eq!(r1.face.family, "Source Serif 4");
assert_eq!(r1.face.id(), r2.face.id());
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX));
}
/// The name-based predicates look at every candidate, and their answer is cached per list: a
/// later Thai or named fallback still counts, and a list is classified once rather than for
/// every piece of text shaped in it.
#[test]
fn list_classification_reads_every_candidate_and_is_cached() {
// The Thai family is last, past any shorter bound, and still classifies the list.
let mut candidates: Vec<String> = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect();
candidates.push("TH Sarabun New".to_string());
let thai_list = candidates.join(";");
assert!(is_thai_family(&thai_list), "a later Thai family still counts");
// A named substitute at the end classifies too (Times New Roman has Liberation Serif).
candidates.pop();
candidates.push("Times New Roman".to_string());
let named_list = candidates.join(";");
assert!(has_named_substitutes(&named_list), "a later named fallback still counts");
// Both are memoised by list text: a second ask answers without rescanning, and a list
// whose text differs only after the key's hash is not mistaken for the cached one.
assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list));
let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len();
assert!(cached >= 2, "{cached} lists classified");
}
}
+1 -3
View File
@@ -104,9 +104,7 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
for r in rows {
let mut cells = Vec::new();
let mut g = 0usize;
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
// walked once per row.
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
for cell_toks in r {
let mut cell = Cell::default();
let mut content = trim_cell(cell_toks);
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
-11
View File
@@ -460,14 +460,3 @@ fn ligature_characters_split_across_runs_stay_apart() {
let back = import(tex.as_bytes());
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
}
#[test]
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
let t = table::table(&toks, "", 0);
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
}
+5 -23
View File
@@ -323,13 +323,11 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
})
}
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
/// cost its length per further text event.
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
s.push(c);
*have += 1;
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
fn push_capped(s: &mut String, t: &str) {
let have = s.chars().count();
if have < MAX_CELL_CHARS {
s.extend(t.chars().take(MAX_CELL_CHARS - have));
}
}
@@ -467,22 +465,6 @@ mod tests {
])
}
#[test]
fn a_full_cell_takes_further_text_without_recounting() {
let (mut s, mut n) = (String::new(), 0usize);
push_capped(&mut s, &mut n, "é");
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
// A million more text events on the full cell each cost nothing (they used to count it).
let t = std::time::Instant::now();
for _ in 0..1_000_000 {
push_capped(&mut s, &mut n, "y");
}
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
assert!(!s.contains('y'));
}
/// #1287: one long shared string in thousands of cells stays under the text budget.
#[test]
fn xlsx_shared_strings_cannot_blow_up_memory() {
+6 -8
View File
@@ -11,8 +11,6 @@ struct OCell {
kind: String,
value: Option<String>,
text: String,
/// Characters in `text` (see [`push_capped`]).
text_chars: usize,
paras: usize,
}
@@ -79,7 +77,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
{
if c.paras > 0 {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
c.paras += 1;
}
@@ -88,17 +86,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
b"s" if in_p > 0 && skip == 0 => {
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
push_capped(&mut c.text, &" ".repeat(k));
}
}
b"tab" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\t");
push_capped(&mut c.text, "\t");
}
}
b"line-break" if in_p > 0 && skip == 0 => {
if let Some(c) = cell.as_mut() {
push_capped(&mut c.text, &mut c.text_chars, "\n");
push_capped(&mut c.text, "\n");
}
}
_ => {}
@@ -109,7 +107,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
&& skip == 0
&& let Some(c) = cell.as_mut()
{
push_capped(&mut c.text, &mut c.text_chars, &t);
push_capped(&mut c.text, &t);
}
}
Ev::End(n) => {
@@ -183,7 +181,7 @@ fn ods_value(c: &OCell) -> String {
("string", Some(v)) if c.text.trim().is_empty() => {
// Attribute text obeys the same per-cell cap as `<text:p>` text.
let mut s = String::new();
push_capped(&mut s, &mut 0, v);
push_capped(&mut s, v);
s
}
_ => c.text.clone(),
+7 -10
View File
@@ -102,18 +102,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
/// (`rPh`) left out.
fn shared_strings(xml: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut cur: Option<(String, usize)> = None;
let mut cur: Option<String> = None;
let (mut in_t, mut phonetic) = (false, 0usize);
walk(xml, |ev| match ev {
Ev::Start(e) => match e.local_name().as_ref() {
b"si" => cur = Some((String::new(), 0)),
b"si" => cur = Some(String::new()),
b"t" => in_t = true,
b"rPh" => phonetic += 1,
_ => {}
},
Ev::End(n) => match n.as_slice() {
b"si" => {
if let Some((s, _)) = cur.take()
if let Some(s) = cur.take()
&& out.len() < MAX_SHARED
{
out.push(s);
@@ -126,9 +126,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
Ev::Text(t) => {
if in_t
&& phonetic == 0
&& let Some((s, n)) = cur.as_mut()
&& let Some(s) = cur.as_mut()
{
push_capped(s, n, &t);
push_capped(s, &t);
}
}
});
@@ -182,9 +182,6 @@ struct XCell {
style: usize,
value: String,
inline: String,
/// Characters in `value` and `inline` (see [`push_capped`]).
value_chars: usize,
inline_chars: usize,
}
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
@@ -230,9 +227,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
Ev::Text(t) => {
if let Some(c) = cell.as_mut() {
if in_v {
push_capped(&mut c.value, &mut c.value_chars, &t);
push_capped(&mut c.value, &t);
} else if in_is_t && phonetic == 0 {
push_capped(&mut c.inline, &mut c.inline_chars, &t);
push_capped(&mut c.inline, &t);
}
}
}
+3 -93
View File
@@ -51,8 +51,6 @@ pub struct Probe<'a> {
pub width: u32,
pub height: u32,
peak: u64,
/// Whether a PNG has transparency, read from its chunk headers; `None` for other formats.
png_alpha: Option<bool>,
}
/// TIFF compressions decoded within the caller's limits: none, CCITT Group 4, LZW, Deflate (both
@@ -74,48 +72,9 @@ pub fn probe(bytes: &[u8]) -> Result<Probe<'_>, Refusal> {
}
_ => return Err(Refusal::NotAPicture),
};
if format == ImageFormat::Png {
let (width, height, native, alpha) = png_chunks(bytes).ok_or_else(|| Refusal::Broken("its PNG header can't be read".into()))?;
let (width, height, peak) = sized(width, height, native, 0)?;
return Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: Some(alpha) });
}
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?;
let (width, height, peak) = measure(&decoder, held)?;
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak, png_alpha: None })
}
/// A PNG's width, height, the bytes a pixel its decoder gives (samples under 8 bits widened to 8,
/// a palette to RGB, `tRNS` to an alpha channel) and whether it has transparency, from `IHDR` and
/// the chunk headers before the image data. The decoder's own header read inflates an ICC profile
/// (`iCCP`) up to its 512 MiB default limit, for a picture that may only be measured.
fn png_chunks(bytes: &[u8]) -> Option<(u32, u32, u64, bool)> {
let be = |at: usize| bytes.get(at..at.checked_add(4)?).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes);
// The 8-byte signature, then IHDR: length, type, width, height, bit depth, colour type.
if bytes.get(12..16) != Some(b"IHDR".as_slice()) {
return None;
}
let (width, height) = (be(16)?, be(20)?);
let (depth, color) = (*bytes.get(24)?, *bytes.get(25)?);
let mut trns = false;
let mut at = 8usize;
loop {
let len = usize::try_from(be(at)?).ok()?;
match bytes.get(at.checked_add(4)?..at.checked_add(8)?)? {
b"IDAT" => break,
b"IEND" => return None,
b"tRNS" => trns = true,
_ => {}
}
at = at.checked_add(12)?.checked_add(len)?;
}
let (channels, alpha) = match color {
0 => (1 + u64::from(trns), trns),
2 | 3 => (3 + u64::from(trns), trns),
4 => (2, true),
6 => (4, true),
_ => return None,
};
Some((width, height, channels * if depth == 16 { 2 } else { 1 }, alpha))
Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak })
}
/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds
@@ -127,20 +86,16 @@ pub fn probe_dib(dib: Vec<u8>) -> Result<Probe<'static>, Refusal> {
let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?;
measure(&decoder, 0)?
};
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak, png_alpha: None })
Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak })
}
/// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output.
fn measure(decoder: &impl ImageDecoder, held: u64) -> Result<(u32, u32, u64), Refusal> {
let (width, height) = decoder.dimensions();
sized(width, height, u64::from(decoder.color_type().bytes_per_pixel()), held)
}
/// A `width` × `height` picture of `native` bytes a pixel, with its peak (see [`peak_bytes`]).
fn sized(width: u32, height: u32, native: u64, held: u64) -> Result<(u32, u32, u64), Refusal> {
if width == 0 || height == 0 {
return Err(Refusal::Broken("it has no pixels".into()));
}
let native = u64::from(decoder.color_type().bytes_per_pixel());
let peak = peak_bytes(width, height, native, held).ok_or(Refusal::TooLarge { width, height })?;
Ok((width, height, peak))
}
@@ -151,18 +106,6 @@ impl Probe<'_> {
self.peak
}
/// Whether the picture has transparency, from its header: a PNG's from its chunk headers
/// (an ICC profile isn't inflated to say), another format's from its decoder.
pub fn has_alpha(&self) -> bool {
if let Some(alpha) = self.png_alpha {
return alpha;
}
if self.dib {
return image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).is_ok_and(|d| d.color_type().has_alpha());
}
image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format).into_decoder().is_ok_and(|d| d.color_type().has_alpha())
}
/// Whether decoding fits `budget` bytes.
pub fn fits(&self, budget: u64) -> bool {
self.peak <= budget
@@ -362,39 +305,6 @@ mod tests {
!c
}
/// `png` with a chunk of `kind` holding `data` placed after IHDR.
fn with_chunk(png: &[u8], kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut chunk = (data.len() as u32).to_be_bytes().to_vec();
chunk.extend_from_slice(kind);
chunk.extend_from_slice(data);
let crc = crc32(&chunk[4..]);
chunk.extend_from_slice(&crc.to_be_bytes());
let mut out = png.get(..33).unwrap().to_vec();
out.extend(chunk);
out.extend_from_slice(&png[33..]);
out
}
#[test]
fn a_png_is_measured_from_its_chunk_headers() {
// An ICC profile isn't inflated to measure a picture: one whose deflate stream is junk
// (the decoder refused it while reading the header) still measures, and isn't decoded.
let rgb = encode(image::RgbImage::new(4, 2), ImageFormat::Png);
let icc = with_chunk(&rgb, b"iCCP", b"p\0\0\xFF\xFF\xFF\xFF");
let p = probe(&icc).unwrap();
assert_eq!((p.width, p.height, p.peak_bytes()), (4, 2, 4 * 2 * 8));
assert!(!p.has_alpha());
assert_eq!(probe(&rgb).unwrap().peak_bytes(), p.peak_bytes(), "as the decoder measures it");
// Transparency: an alpha channel, or tRNS before the image data.
assert!(probe(&encode(image::GrayAlphaImage::new(2, 2), ImageFormat::Png)).unwrap().has_alpha());
let trns = with_chunk(&rgb, b"tRNS", &[0, 0, 0, 0, 0, 0]);
let p = probe(&trns).unwrap();
assert!(p.has_alpha());
assert_eq!(p.decode(1 << 20).map(|i| i.color().has_alpha()), Ok(true), "the decoder agrees");
// No image data: broken.
assert!(probe(&rgb[..33]).is_err());
}
#[test]
fn small_pictures_decode_and_peaks_count_every_buffer() {
let png = encode(image::RgbaImage::from_pixel(16, 8, image::Rgba([1, 2, 3, 4])), ImageFormat::Png);
+11 -17
View File
@@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool {
let layout = table::layout_table(ctx, story, t, path, width, depth + 1);
let (tx, ty) = match t.props.float {
Some(float) => {
let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t));
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables);
exclusions.push((area, false, None, WrapText::BothSides, true));
float_tables.push(area);
@@ -366,12 +366,8 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
items.splice(at..at, back);
items.extend(front);
}
// Automatic space after: none after the last paragraph of a cell or box. Nor
// contextual spacing after a cell's last paragraph in the default style (or an
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
let last = i + 1 == blocks.len();
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
y += edges.top + pl.height + edges.bottom + after;
prev_after = after;
prev_after_auto = pl.rp.after_auto;
@@ -468,15 +464,14 @@ pub(crate) struct BoxLayout {
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
/// the area text keeps clear of. Positions relative to the page or its margins need the box's
/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's
/// left margin before Word 2013 layout, `None` in it.
/// `frame`; without one (a table cell) they're relative to the box.
fn box_float_table_rect(
frame: Option<PageFrame>,
width: f32,
y: f32,
tl: &table::TableLayout,
f: &TableFloat,
legacy: Option<f32>,
legacy: f32,
float_tables: &[Rect],
) -> (Rect, Rect) {
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
@@ -512,19 +507,18 @@ fn box_float_table_rect(
};
// Nor is there an area to align in: the table starts where the text is.
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 };
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables);
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 };
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
let [dl, dt, dr, db] = f.dist_from_text();
(r, r.inset(-dl, -dt, -dr, -db))
}
/// Floating table `f` at `r`, moved below the floating tables in its way when it may not
/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from
/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first
/// cell has no left margin.
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect {
/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's
/// left edge (`text_left`).
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect {
let [dl, dt, dr, db] = f.dist_from_text();
let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
for _ in 0..64 {
let area = r.inset(-dl, -dt, -dr, -db);
let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break };
+4 -1
View File
@@ -286,13 +286,16 @@ impl Alphas {
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
use image::ImageDecoder;
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
// Formats that can't be transparent aren't decoded.
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
return Ok(None);
}
// Only the header is read here (a PNG's `tRNS` counts as transparency).
if !pic.has_alpha() {
let has_alpha =
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
if !has_alpha {
return Ok(None);
}
let pixels = u64::from(pic.width) * u64::from(pic.height);
+5 -9
View File
@@ -4,12 +4,9 @@ use super::headers::HfLinks;
use super::placed::Placed;
use super::{
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
const LINE_NUMBER_STYLE: &str = "LineNumber";
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
pub(crate) enum Pass {
@@ -196,11 +193,10 @@ impl Ctx<'_> {
if let Some(p) = self.numbers.get(&n) {
return p.clone();
}
// Word formats line numbers with the "Line Number" character style over the document's
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
// Aptos 12 pt), not in a fixed small grey.
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
let mut p = Paragraph::with_text(
&n.to_string(),
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
);
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
let env = para::ParaEnv {
+1 -12
View File
@@ -637,20 +637,9 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
}
let cx = x + line.cl_left(k).unwrap_or(0.0);
let obj = para.and_then(|p| p.objects.get(oi));
let modern = doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern);
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k));
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
match obj {
// A turn its line keeps no room for overhangs the line; Word cuts it at the line's top
// and bottom, not at its sides.
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. }))
if o.frame().is_some_and(|(w, h, f)| f.is_bare_turn() && f.spin_pad(w, h).1 > f.line_pad(w, h, modern).1 + 0.01) =>
{
let mut items = Vec::new();
object_draws(o, rect, Spin::default(), alpha, opts, &mut items);
let band = Rect::new(rect.x - 4000.0, ctx.top, rect.w + 8000.0, (ctx.bottom - ctx.top).max(0.0));
out.push(Draw::Clip { rect: band, items });
}
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
object_draws(o, rect, Spin::default(), alpha, opts, out)
}
+4 -5
View File
@@ -487,9 +487,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
/// `obj_h` above the baseline and `obj_d` below it): inside the room kept for its effects.
/// A rotated one's unrotated frame, centred in that room.
///
/// `line` is the room a horizontal rule is placed in, from `cx`; `modern`: Word 2013+ layout
/// (see [`wordcraft_doc::para::Float::line_pad`]).
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32, modern: bool) -> Rect {
/// `line` is the room a horizontal rule is placed in, from `cx`.
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
let frame = obj.and_then(InlineObject::frame);
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
// Lowered further than its height, it is drawn with its top on the baseline (Word).
@@ -504,9 +503,9 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f
return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h);
}
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
// The room is what the line kept for the turned frame: undo that.
// The room is the rotated bounds of the frame: undo the rotation.
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
let bw = w + 2.0 * float.line_pad(w, h, modern).0;
let (bw, _) = float.spin().extent(w, h);
let k = if bw > 0.0 { room.w / bw } else { 1.0 };
let (fw, fh) = (w * k, h * k);
Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh)
+4 -15
View File
@@ -438,23 +438,13 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262). The text may be in the same row's
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
// A wrap can leave only the zero-width anchor in the row's left segment, with its
// text on the right. That is still visible content after the break, not a cover box.
let lead = after_break
&& !clusters.iter().any(para::takes_room)
&& !pl
.lines
.iter()
.skip(li + 1)
.take_while(|next| next.beside)
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
@@ -761,8 +751,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
}
} else {
let cx = x + line.cl_left(k).unwrap_or(0.0);
let modern = ctx.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern)
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k))
};
let (mut behind, mut front) = (Vec::new(), Vec::new());
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
+1 -13
View File
@@ -281,17 +281,6 @@ impl<'a> Flow<'a> {
if self.pl.rp.before_auto && self.block == 0 {
before = 0.0;
}
// An empty paragraph after a document's leading table, placed floating on this page, has no
// space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place).
// Only that shape is observed: later pages and columns keep theirs.
if self.block == 1
&& pb.fresh()
&& !pb.web
&& pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0)))
&& only_markers(self.p, 0..self.p.text.len())
{
before = 0.0;
}
before
}
@@ -827,8 +816,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let mut f = Flow::new(ctx, pb, p, (block, body_top));
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
let placed_at = spot(pb);
// A continuous section's top mid-page is not a page top, so the break still applies.
let broke_before = f.pl.rp.page_break_before && !pb.fresh() && !pb.web;
let broke_before = f.pl.rp.page_break_before && !pb.at_top() && !pb.web;
if broke_before {
pb.new_page(block, body_top);
}
+3 -7
View File
@@ -130,17 +130,13 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria";
/// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing.
pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
let db = wordcraft_fonts::FontDb::global();
let roman = !doc.settings.roman_fonts.is_empty()
&& font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f)));
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
if roman
&& !wordcraft_fonts::word::is_thai_family(font)
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
&& !wordcraft_fonts::word::has_named_substitutes(font)
&& if font.contains(';') {
wordcraft_fonts::word::resolve(font, false, false).substituted
} else {
!db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font))
}
&& !db.has_family(font)
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
{
MISSING_ROMAN_FONT
} else {
+12 -36
View File
@@ -92,8 +92,6 @@ struct Scan {
j: usize,
/// Tab cluster, stop, and x where the tab started.
pending_tab: Option<(usize, TabStop, f32)>,
/// The text after a pending decimal tab looked at so far for its '.'.
decimal: Option<DecimalScan>,
/// The width of the spaces so far on this line.
space_w: f32,
has_tab: bool,
@@ -115,15 +113,17 @@ pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
/// How far the text after a decimal tab has been looked at for its first '.' (see
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
/// whether that cluster is the '.'.
#[derive(Clone, Copy, Default)]
struct DecimalScan {
tab: usize,
next: usize,
width: f32,
found: bool,
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let mut w = 0.0;
for k in tj + 1..=j {
let cc = pl.clusters.get(k)?;
if pl_text_at(cc) {
return Some(w);
}
w += cc.adv;
}
None
}
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
@@ -279,7 +279,6 @@ impl<'e> Breaker<'e> {
end: LineEnd::Para,
j: self.i,
pending_tab: None,
decimal: None,
space_w: 0.0,
has_tab: false,
skip: false,
@@ -918,29 +917,6 @@ impl Scan {
false
}
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
/// text is shifted after each of its clusters, so the clusters already looked at are not
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
/// per cluster.
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
let d = self.decimal.get_or_insert(start);
if d.tab != tj || d.next > j.saturating_add(1) {
*d = start;
}
while !d.found && d.next <= j {
crate::work::steps(1);
let cc = pl.clusters.get(d.next)?;
if pl_text_at(cc) {
d.found = true;
} else {
d.width += cc.adv;
d.next += 1;
}
}
d.found.then_some(d.width)
}
/// Text after a decimal/centre/right tab shifts as it grows.
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
let Some((tj, stop, tx)) = self.pending_tab else { return };
@@ -949,7 +925,7 @@ impl Scan {
TabAlign::Right => stop.pos - tx - seg_w,
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
// Width up to the first '.' in the segment.
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
_ => 0.0,
};
let shift = shift.max(0.0);
+10 -19
View File
@@ -397,7 +397,7 @@ impl<'a> Shaped<'a> {
// The line makes room for effects (shadows) around the picture too, and
// for a rotated one's bounds, as Word does.
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT);
let (px, py) = float.spin_pad(w * s, h * s);
let tall = h * s + et + eb + 2.0 * py;
// Lowered further than its height, its top stays on the baseline while the line
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
@@ -837,36 +837,27 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec
}
// Word breaks after a hyphen even when a digit follows ("2016-" / "2022"), where UAX #14
// keeps a hyphen and a number together (LB25), and when a slash and a letter follow
// ("Dataprotection-" / "/Secrecyviolation", #3408), where LB13 forbids a break before the
// slash: add those positions back. Word keeps "fitness-/1589" whole, so a slash before a digit
// gains no break. Only U+002D is added here; a non-breaking hyphen (U+2011) and a minus sign
// (U+2212) stay unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
// keeps a hyphen and a number together (LB25): add those positions back. Only U+002D before
// ASCII digits is added here; a non-breaking hyphen (U+2011) and a minus sign (U+2212) stay
// unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
let hyphen_breaks: Vec<usize> = text
.match_indices('-')
.filter_map(|(pos, _)| {
let i = pos + 1;
let mut next = text.get(i..).unwrap_or_default().chars();
match next.next() {
Some(n) if n.is_ascii_digit() => Some(i),
Some('/') => next.next().is_some_and(char::is_alphabetic).then_some(i),
_ => None,
}
text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i)
})
.collect();
found.extend(hyphen_breaks);
// The breaks added above come after UAX #14's: put them back in order.
found.sort_unstable();
found.dedup();
if !hyphen_breaks.is_empty() {
found.extend(hyphen_breaks);
found.sort_unstable();
found.dedup();
}
// Myanmar has no spaces between words: it wraps between syllables.
if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) {
found.extend(wordcraft_doc::cluster::myanmar_breaks(text));
found.sort_unstable();
found.dedup();
}
// UAX #14 breaks between a full stop and an opening parenthesis; Word keeps
// "2023.(Example" whole and wraps it at the space before.
found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('('))));
// Asian typography: kinsoku and breaking Latin words anywhere.
crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap);
found
+9 -27
View File
@@ -2,7 +2,7 @@ use std::collections::VecDeque;
use super::cache::{LayoutCache, Totals, env_hash, hash_of};
use super::ctx::{Ctx, Pass, with_pct_base};
use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects};
use super::floats::{EarlyFloats, WrapArea, early_covered};
use super::flow::{OutlinePass, place_outline_para, place_para};
use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side};
use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes};
@@ -10,10 +10,9 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom};
use super::placed::Placed;
use super::table_flow::place_table;
use super::{
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart,
StoryRef, TextDirection, ViewMode, balloons, para, vertical,
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection,
ViewMode, balloons, para, vertical,
};
use wordcraft_doc::legacy_form::LegacyForm;
/// How the view lays the document out: `web` for the continuous views, `reflow` for those and
/// the reading view, which both ignore the sections' page geometry.
@@ -128,35 +127,18 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect
end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty())
});
if !opts.show_hidden {
quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| {
b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i)
}));
quiet.extend(
doc.body
.iter()
.enumerate()
.filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)),
);
}
quiet.sort_unstable();
quiet.dedup();
(stacked, quiet)
}
/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other
/// than its result, is visible content with no plain text, so its line keeps its room even when
/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one
/// in hidden or deleted text).
fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool {
let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body));
// As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry.
let shown_field = |o: &InlineObject| match o {
InlineObject::Field { code, .. } => {
*code != opts.field_codes
|| o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty()))
}
_ => false,
};
p.objects
.iter()
.enumerate()
.any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o)))
}
/// The page the continuous views lay out on: the first section's, as wide as the window (and as
/// high in the reading view), with small margins and one column.
fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
+2 -4
View File
@@ -1201,16 +1201,14 @@ fn align_in_part(
}
let limit = cut - row.bottom.clamp(0.0, cut);
let mut kept = f32::MIN;
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
// No further than the cell's content: its last paragraph may have dropped its space after.
let end = end.unwrap_or(f32::MAX);
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
for (it, &split) in items.iter().zip(splits) {
let bottom = match (it.turned_bounds(), it) {
(Some(r), _) => Some(r.bottom()),
// A paragraph kept whole ends with its space after.
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
.filter(|&b| b <= limit + 0.01)
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
Some(rect.bottom())
}
+8 -69
View File
@@ -94,62 +94,7 @@ fn row_keeps_next(row: &table::RowLayout) -> bool {
.unwrap_or(false)
}
/// The cut that leaves every row of a nested table marked "can't split" whole: just below the
/// top of the highest such row the cut at `room` runs through, moved up again while it runs
/// through another one (Word moves such a row on with the rest of the outer row, observed in the
/// docxide-pdf `nested-first-row-*-cant1` cases). Like the split itself, a row "runs through"
/// the cut when it ends below the room left for text there, above the row's bottom band; the cut
/// goes that band below the row's top, so the text and rows ending at its top stay.
/// shortcut: the moved row's side borders and shading keep that band's height on the page, and
/// on the next page its cell area and shading start that band less its top margin below its
/// first line (clicks there miss the cell). Only an explicit bottom cell margin or a thick outer
/// border shows it, never Word's defaults; upgrade with a separate drawing cut in split_row that
/// moves a nested cell starting between the limit and the cut whole from its top.
fn nested_keep_cut(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> f32 {
let own = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min();
// Each nested row's top and bottom when it can't split. A vertically merged cell runs past
// its own row, so a row ends where its shortest cell does.
let mut bands: std::collections::BTreeMap<(StoryRef, &[u32], usize), Option<(f32, f32)>> = std::collections::BTreeMap::new();
// The split runs a cell aligned to the middle or bottom from its top: so do its bands.
let mut shift = vec![0.0f32; row.items.len()];
for al in row.cells.iter().filter_map(|c| c.align.as_ref()) {
shift.get_mut(al.content.clone()).into_iter().flatten().for_each(|d| *d = al.dy);
}
for (it, &dy) in row.items.iter().zip(&shift) {
let Placed::Cell { rect, table, row: r, story, .. } = it else { continue };
if Some(table.0.len()) <= own {
continue;
}
let rect = Rect { y: rect.y - dy, ..*rect };
let band = bands
.entry((*story, table.0.as_slice(), *r))
.or_insert_with(|| doc.table(*story, table).and_then(|t| t.rows.get(*r)).filter(|r| r.props.cant_split).map(|_| (rect.y, rect.bottom())));
if let Some((top, bottom)) = band {
*top = top.min(rect.y);
*bottom = bottom.min(rect.bottom());
}
}
let below = row.bottom.clamp(0.0, room.max(0.0));
let mut bands: Vec<(f32, f32)> = bands.into_values().flatten().collect();
// From the lowest top up: once the cut moves up to a row, the rows starting below it are
// wholly under it, and a row the old cut ran through still runs through the new one.
bands.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut cut = room;
for (top, bottom) in bands {
let limit = cut - below;
if top < limit && bottom > limit + 0.01 {
cut = top + below;
}
}
cut
}
/// [`table::split_row_keeping_cells`] that doesn't split a nested "can't split" row either.
fn split_keeping_nested(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> Option<(table::RowLayout, table::RowLayout)> {
table::split_row_keeping_cells(row, nested_keep_cut(doc, row, room))
}
fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
if laid.height <= room + 0.01 {
return true;
}
@@ -159,7 +104,7 @@ fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid:
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
return false;
}
split_keeping_nested(doc, laid, room).is_some()
table::split_row_keeping_cells(laid, room).is_some()
}
pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
@@ -242,7 +187,8 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
{
let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::<f32>();
let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split);
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) {
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some()))
{
pb.advance(block, body_top);
}
}
@@ -277,7 +223,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
}
let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false };
let page_height = pb.bottom - body_top - repeated_height;
height <= page_height + 0.01 && !row_can_start(ctx.doc, next, laid, pb.bottom - pb.y - height, page_height)
height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height)
});
// A row another row follows needs room for the border below it too (measured in Word).
if pb.y + cur.height + cur.edge <= pb.bottom + 0.01 && !defer_kept {
@@ -292,9 +238,8 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
let moves_on = first_part && short_of_min(ri, room);
let split = match (splittable && !moves_on && !defer_kept, first_part) {
(false, _) => None,
(true, true) => split_keeping_nested(ctx.doc, cur, room),
// On a fresh page, a nested row taller than the room splits rather than overflowing.
(true, false) => table::split_row(cur, nested_keep_cut(ctx.doc, cur, room)).or_else(|| table::split_row(cur, room)),
(true, true) => table::split_row_keeping_cells(cur, room),
(true, false) => table::split_row(cur, room),
};
match split {
Some((a, b)) => {
@@ -349,13 +294,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
let x = float_table_x(pb, w, f, legacy, ctx.mirror);
let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) };
obstacles.extend_from_slice(&pb.float_tables);
let r = clear_of_float_tables(
Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h),
f,
ctx.doc.settings.compat_mode >= 15,
h_area.0,
&obstacles,
);
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles);
// Like Word, a table that doesn't fit below the text before it moves to the next page with
// its anchor: one placed relative to the paragraph when it would run off the page, one
// placed on the page or margin when it would also cover the text already on the page.
+4 -485
View File
@@ -9,12 +9,10 @@ mod bidi;
mod budget;
mod cell_floats;
mod complex;
mod float_top;
mod grid;
mod ligature;
pub(crate) mod linear;
mod nbsp;
mod nested_cant_split;
mod numeral;
mod pages;
mod styleref;
@@ -298,82 +296,6 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() {
}
}
/// Baseline of the paragraph after a one-row table, less that of the first cell's last
/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell
/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is
/// the last paragraph's text.
fn cell_end_gap(style: &str, contextual: bool) -> f32 {
cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe")
}
fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 {
use wordcraft_doc::props::LineSpacing;
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "Other".into(),
name: "Other".into(),
based_on: Some("Normal".into()),
..Default::default()
});
let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() };
let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() };
let para = |text: &str, props: ParaProps| {
let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone());
p.props = props;
wordcraft_doc::para_block(p)
};
let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() };
let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0);
table.props.style = None;
table.props.cell_margins = Some([0.0; 4]);
let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() };
table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)];
if let Some(cell) = table.rows[0].cells.get_mut(1) {
cell.blocks = vec![para("Delta probe", styled(next_cell))];
}
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))];
let l = lay(&d);
let baseline = |at: &[u32]| {
l.pages[0].items.iter().find_map(|i| match i {
Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline),
_ => None,
})
};
let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") };
after - cell
}
#[test]
fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() {
// The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a
// Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not
// after one in another style (`cell-end-other-style`) or without contextual spacing.
let kept = cell_end_gap("Normal", false);
assert!((cell_end_gap("Other", true) - kept).abs() < 0.01);
let dropped = cell_end_gap("Normal", true);
assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}");
// An undefined style is formatted as Normal, and drops it too.
assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01);
// The mark decides, not a neighbour: another style in the next cell or after the table
// leaves the drop as it is.
for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] {
let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe");
assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}");
}
// And the reverse: another style keeps its space beside Normal neighbours.
let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe");
assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}");
// An empty last paragraph drops it too (`cell-end-empty-context`).
let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, "");
assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}");
// Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the
// 12 pt stays.
let mut legacy = Document::new();
legacy.settings.compat_mode = 14;
legacy.settings.allow_space_of_same_style_in_table = true;
let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe");
assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}");
}
fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 {
use wordcraft_doc::props::LineSpacing;
let mut d = Document::new();
@@ -3927,40 +3849,6 @@ fn extreme_page_and_line_number_starts_do_not_overflow() {
assert!(l.pages.len() >= 3, "{} pages", l.pages.len());
}
/// Line numbers take the document's character formatting through the "Line Number" style, as in
/// Word (Aptos 12 pt black in a document whose defaults are Aptos 12 pt), not a fixed small grey.
#[test]
fn line_numbers_follow_the_line_number_style() {
let number_runs = |d: &Document| {
let l = lay(d);
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
_ => None,
})
.collect::<Vec<_>>()
};
let mut d = Document::from_text("one\ntwo\nthree");
d.last_section.line_numbers = Some(Default::default());
Arc::make_mut(&mut d.styles).default_chr.size = Some(12.0);
let plain = number_runs(&d);
assert_eq!(plain.len(), 3);
assert!(plain.iter().all(|(size, color)| *size == 12.0 && *color == wordcraft_doc::TextColor::Auto), "{plain:?}");
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "LineNumber".into(),
name: "line number".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
..Default::default()
});
let styled = number_runs(&d);
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
}
#[test]
fn line_numbers_borders_text_boxes() {
let mut d = Document::from_text("one\ntwo\nthree");
@@ -5705,7 +5593,7 @@ fn inline_picture_keeps_room_for_its_effects() {
assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents");
// Drawn inside that room.
let obj = picture(100.0, 50.0, shadow);
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
}
@@ -5732,67 +5620,22 @@ fn an_inline_picture_moves_with_its_runs_position() {
let (c, line, _) = line_of(-30.0);
assert_eq!((c.obj_h, c.obj_d), (0.0, 30.0));
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Lowered further than that: the line reaches as far down, the picture's top staying on the
// baseline (Word).
let (c, line, _) = line_of(-500.0);
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Raised: it stands clear of the baseline, as tall as before.
let (c, _, _) = line_of(6.0);
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (64.0, 30.0));
}
/// A turned inline picture whose file kept no room for the turn: Word keeps its line to the frame
/// (Word 2007 layout) or to the frame with width and height exchanged near a quarter turn (Word
/// 2013+), and the picture overhangs it. One with room recorded takes its rotated bounds.
#[test]
fn bare_turned_inline_picture_keeps_its_frame() {
let room = |rot: f32, bare_turn: bool, compat: u32| {
let mut d = Document::from_text("");
d.settings.compat_mode = compat;
let float = wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() };
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.insert_object(0, picture(120.0, 60.0, float), &Default::default()).unwrap();
}
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
// Drawn unscaled, centred in that room.
let obj = picture(120.0, 60.0, wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() });
let r = display::inline_rect(Some(&obj), 0.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, compat >= 15);
assert_eq!((r.w, r.h), (120.0, 60.0), "{rot}° bare {bare_turn} compat {compat}");
assert_eq!((r.x + r.w / 2.0, r.y + r.h / 2.0), (c.adv / 2.0, 200.0 - c.obj_h / 2.0));
// Only a bare turn that overhangs its line is cut, at the line's top and bottom.
let overhangs =
bare_turn && (c.obj_h as f64) < 120.0 * (rot as f64).to_radians().sin().abs() + 60.0 * (rot as f64).to_radians().cos().abs() - 0.1;
let clips: Vec<Rect> = display::page_display(&d, &l.pages[0], &Default::default())
.iter()
.filter_map(|i| if let display::Draw::Clip { rect, .. } = i { Some(*rect) } else { None })
.collect();
assert_eq!(clips.len(), usize::from(overhangs), "{rot}° bare {bare_turn} compat {compat}: {clips:?}");
if let Some(band) = clips.first() {
assert!(band.h >= c.obj_h && band.w > 4000.0, "{band:?}");
}
(c.adv.round(), c.obj_h.round())
};
assert_eq!(room(90.0, true, 12), (120.0, 60.0));
assert_eq!(room(45.0, true, 12), (120.0, 60.0));
assert_eq!(room(180.0, true, 12), (120.0, 60.0));
assert_eq!(room(90.0, true, 15), (60.0, 120.0));
assert_eq!(room(45.0, true, 15), (60.0, 120.0));
assert_eq!(room(135.0, true, 15), (120.0, 60.0));
assert_eq!(room(270.0, true, 15), (60.0, 120.0));
// Room recorded (Word's own files, WordCraft's turns): the rotated bounds, in any layout.
assert_eq!(room(90.0, false, 12), (60.0, 120.0));
assert_eq!(room(45.0, false, 15), (127.0, 127.0));
}
/// Tops of the body text lines drawn on page one, in order.
fn line_tops(l: &DocLayout) -> Vec<f32> {
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect()
@@ -7125,48 +6968,6 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() {
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
}
/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also
/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in
/// the body and in a table cell.
#[test]
fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let narrow_x = |mode: u32, in_cell: bool| {
let mut d = Document::from_text("Body.");
d.settings.compat_mode = mode;
let table = |w: f32, text: &str, dist: [f32; 4]| {
let mut t = Table::new(1, 1, w);
t.props.cell_margins = Some([0.0; 4]);
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() });
t
};
let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])];
if in_cell {
let mut outer = Table::new(1, 1, 468.0);
outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect();
outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default())));
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap();
} else {
for (i, t) in floats.into_iter().enumerate() {
d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap();
}
}
let l = lay(&d);
let xs: Vec<(f32, f32)> =
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect();
let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") };
assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}");
nx - wx
};
for in_cell in [false, true] {
let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell));
assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one");
assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one");
}
}
/// A margin-anchored floating table is placed from the anchor page's margin even when a body
/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every
/// table row is drawn exactly once there.
@@ -9090,34 +8891,6 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() {
assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}");
}
#[test]
fn superscript_digits_advance_at_the_half_point_size() {
use wordcraft_doc::props::VertAlign;
// Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its
// digits advance by 7pt digit widths (Word for Mac, measured from its PDF export).
let width = |size: f32, vert_align: VertAlign| {
let mut p = wordcraft_doc::Paragraph::new();
let props =
wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() };
p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() });
p.text.push_str("1234567890");
p.mark = props;
let mut d = Document::new();
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
// From the first digit's start to the last's: nine digit advances.
let xs = l.pages[0]
.items
.iter()
.find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None })
.unwrap();
xs.last().unwrap() - xs.first().unwrap()
};
let seven = width(7.0, VertAlign::Baseline);
assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript));
assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01);
}
#[test]
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
use wordcraft_doc::para::RubyAlign;
@@ -10465,20 +10238,6 @@ fn empty_page_section_keeps_line() {
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
/// many continuous breaks.
/// A continuous section starting mid-page leaves its first paragraph's page break before in
/// force, measured in Word for Mac: "Cover", an empty mark closing a continuous section, then
/// "Heading" with page break before puts "Heading" on page 2.
#[test]
fn a_page_break_before_after_a_continuous_mark_starts_a_page() {
use wordcraft_doc::section::{SectionProps, SectionStart};
let mut d = Document::from_text("Cover\n\nHeading");
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true);
d.last_section.start = SectionStart::Continuous;
assert_eq!(lay(&d).caret(&Pos::body(2, 0)).unwrap().page, 1);
}
#[test]
fn an_empty_continuous_section_mark_takes_no_room() {
use wordcraft_doc::section::{SectionProps, SectionStart};
@@ -10512,79 +10271,6 @@ fn an_empty_continuous_section_mark_takes_no_room() {
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
}
/// A hidden mark closing a section before a continuous one, holding only an in-line picture:
/// the picture keeps its line, so the next section starts below it, not over it (#5288). A
/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden
/// text.
#[test]
fn a_hidden_mark_with_an_inline_picture_keeps_its_room() {
use wordcraft_doc::para::{Float, ShapeKind};
use wordcraft_doc::section::{SectionProps, SectionStart};
// Where Pb starts, and the bottom of the closing mark's picture (none: no picture).
let tops = |float: Option<Float>, run: CharProps| {
let mut d = Document::from_text("Pa\n\nPb");
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
closer.mark.hidden = Some(true);
closer.section = Some(Box::new(SectionProps::default()));
d.last_section.start = SectionStart::Continuous;
if let Some(float) = float {
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
d.insert_object(&Pos::body(1, 0), shape, &run).unwrap();
}
let l = lay(&d);
let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom());
(l.caret(&Pos::body(2, 0)).unwrap(), bottom)
};
let wrapped = |wrap| Some(Float { wrap, ..Default::default() });
let (empty, _) = tops(None, CharProps::default());
let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default());
let bottom = bottom.unwrap();
assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}");
assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}");
assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}");
let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default());
assert!(drawn.is_some(), "the floating picture is drawn");
assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}");
let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default());
assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}");
let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() });
assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}");
}
/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result):
/// the box is drawn with a line's room, so the next section starts below it (#5288).
#[test]
fn a_hidden_mark_with_a_check_box_keeps_its_room() {
use wordcraft_doc::section::{SectionProps, SectionStart};
let top = |boxed: bool| {
let mut d = Document::from_text("Pa\n\nPb");
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
closer.mark.hidden = Some(true);
closer.section = Some(Box::new(SectionProps::default()));
d.last_section.start = SectionStart::Continuous;
if boxed {
let form = r#"<w:ffData><w:name w:val="Box"/><w:checkBox><w:sizeAuto/></w:checkBox></w:ffData>"#.to_string();
let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form };
d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap();
}
lay(&d).caret(&Pos::body(2, 0)).unwrap()
};
let (empty, boxed) = (top(false), top(true));
assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}");
}
/// Empty marks closing continuous sections one after another each keep their line while it
/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text
/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End":
@@ -12262,88 +11948,6 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() {
assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}");
}
/// The line tops on the first page of a row split across pages, by cell: a label aligned with
/// `valign`, 30 lines that split, and a 160 pt spacer above `note`.
fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec<f32>; 3] {
use wordcraft_doc::props::LineSpacing;
let plain = split_note_plain();
let para = |text: &str, props: ParaProps| {
let mut p = Paragraph::with_text(text, Default::default());
p.props = props;
wordcraft_doc::para_block(p)
};
let mut d = Document::new();
(d.last_section.page_w, d.last_section.page_h) = (400.0, 360.0);
d.last_section.margin_top = 36.0;
d.last_section.margin_bottom = 36.0;
let mut t = Table::new(1, 3, 328.0);
t.props.style = None;
t.props.cell_margins = Some([0.0; 4]);
t.rows[0].cells[0].props.valign = Some(valign);
t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())];
t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect();
let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() };
t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())];
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))];
let l = lay(&d);
let tops = |cell: u32| -> Vec<f32> {
l.pages[0]
.items
.iter()
.filter_map(|item| match item {
Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y),
_ => None,
})
.collect()
};
let cells = [tops(0), tops(1), tops(2)];
assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}");
assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}");
cells
}
/// Single 12 pt lines with no space around them.
fn split_note_plain() -> ParaProps {
ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)),
widow_control: Some(false),
..Default::default()
}
}
#[test]
fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would
// otherwise reach below the split lines: a bottom label lines up with those, a middle one is
// centred between their top and bottom.
let plain = split_note_plain();
let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() };
let automatic = ParaProps { space_after_auto: Some(true), ..plain };
for note in [contextual, automatic] {
let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, &note);
assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}");
let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, &note);
let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") };
let want = first + (last - first) / 2.0;
assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}");
}
}
#[test]
fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom
// label's line ends 14 pt below the note's.
let note = ParaProps { space_after: Some(14.0), ..split_note_plain() };
let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, &note);
let (Some(&label), Some(&note_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") };
assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}");
assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}");
}
fn row_keep_pages(d: &Document) -> Vec<usize> {
let l = lay(d);
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
@@ -13887,91 +13491,6 @@ fn a_non_breaking_hyphen_and_a_minus_sign_stay_unbreakable() {
}
}
/// #3408: Word breaks a compound right after a hyphen that a slash follows ("Dataprotection-" /
/// "/Secrecyviolation"), where UAX #14 forbids a break before the slash. The first width that
/// wraps must break after the hyphen, not move the compound whole, and the slash stays with its
/// word. Table auto-fit reads the same break, so such a column no longer widens to the compound.
/// Word keeps "fitness-/1589" whole: a slash before a digit gains no break.
#[test]
fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() {
let digits = "Deed fitness-/1589";
let mut wrapped = false;
for m in 100..=540 {
let mut d = Document::from_text(digits);
d.last_section.margin_right = m as f32;
let l = lay(&d);
let lines: Vec<&str> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, l0, l1, .. } = it { Some(&para.lines[*l0..*l1]) } else { None })
.flatten()
.map(|line| &digits[line.start..line.stop])
.collect();
if lines.len() >= 2 {
assert_eq!(lines, ["Deed ", "fitness-/1589"], "the first wrap breaks at the space, not after the hyphen");
wrapped = true;
break;
}
}
assert!(wrapped, "no width in the sweep wrapped the digit case");
let text = "Deed Dataprotection-/Secrecyviolation";
for m in 100..=540 {
let mut d = Document::from_text(text);
d.last_section.margin_right = m as f32;
let l = lay(&d);
let lines: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let Placed::Lines { para, l0, l1, .. } = it {
Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::<Vec<_>>())
} else {
None
}
})
.flatten()
.collect();
if lines.len() < 2 {
continue; // still fits on one line at this width
}
assert_eq!(lines[0], "Deed Dataprotection-", "the first wrap breaks after the hyphen: {lines:?}");
assert_eq!(lines[1], "/Secrecyviolation", "the slash starts the next line with its word: {lines:?}");
return;
}
panic!("no width in the sweep wrapped the paragraph");
}
/// docxide-pdf `dot-open-token*`: UAX #14 allows a break between a full stop and an opening
/// parenthesis, Word doesn't. The first width that wraps `Deed 2023.(Example` breaks at the space.
#[test]
fn a_full_stop_before_a_parenthesis_is_no_break() {
let text = "Deed 2023.(Example";
for m in 100..=540 {
let mut d = Document::from_text(text);
d.last_section.margin_right = m as f32;
let l = lay(&d);
let lines: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let Placed::Lines { para, l0, l1, .. } = it {
Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::<Vec<_>>())
} else {
None
}
})
.flatten()
.collect();
if lines.len() < 2 {
continue; // still fits on one line at this width
}
assert_eq!(lines, ["Deed ", "2023.(Example"], "the first wrap breaks at the space");
return;
}
panic!("no width in the sweep wrapped the paragraph");
}
#[test]
fn layout_cache_invalidates_on_changed_filename() {
let mut doc = Document::new();
-53
View File
@@ -1,53 +0,0 @@
//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs
//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at
//! the same place for 0 pt and 12 pt before.
use super::lay;
use crate::{Arc, Placed, StoryRef};
use wordcraft_doc::para::{Anchor, InlineObject};
use wordcraft_doc::props::TableFloat;
use wordcraft_doc::{Block, Document, Paragraph, Table, para_block};
/// Where the lines of body block `block` start on page one.
fn line_y(d: &Document, block: u32) -> f32 {
lay(d).pages[0]
.items
.iter()
.find_map(|i| match i {
Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y),
_ => None,
})
.unwrap()
}
/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After".
fn document(table: bool, text: &str, before: f32) -> Document {
let mut p = Paragraph::with_text(text, Default::default());
// "#" stands for a bookmark, a zero-width marker.
if text == "#" {
p = Paragraph::new();
p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap();
}
p.props.space_before = Some(before);
let mut d = Document::new();
d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))];
if table {
let mut t = Table::new(1, 1, 200.0);
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))];
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() });
d.body.insert(0, Arc::new(Block::Table(t)));
}
d
}
#[test]
fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() {
let empty = line_y(&document(true, "", 12.0), 2);
let plain = line_y(&document(false, "", 0.0), 1);
assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}");
// A bookmark alone doesn't make it a paragraph with text.
assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01);
// One with text keeps it, and so does an empty first paragraph with no table before it.
assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99);
assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99);
}
-22
View File
@@ -482,25 +482,3 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
}
/// A paragraph of a decimal tab at 200 pt followed by `text`.
fn after_decimal_tab(text: &str) -> Document {
use wordcraft_doc::props::{TabAlign, TabStop};
let mut d = Document::from_text(&format!("\t{text}"));
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
d
}
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
/// one line) took quadratic time.
#[test]
fn text_after_a_decimal_tab_is_looked_at_once() {
let opts = LayoutOptions::default();
// The text after the tab still ends at the stop, where it ended before.
let l = lay(&after_decimal_tab("12.5"), &opts);
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
}
@@ -1,213 +0,0 @@
//! A row of a nested table marked "can't split" moves on with its outer row, as in Word
//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it.
use crate::*;
use wordcraft_doc::{Table, para_block};
fn one_line_paras(what: &str, n: usize) -> Vec<Arc<Block>> {
(0..n)
.map(|i| {
let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default());
p.props.space_before = Some(0.0);
p.props.space_after = Some(0.0);
para_block(p)
})
.collect()
}
/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold
/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns,
/// per cell, the prefix lines and the nested lines on each page.
fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec<usize>, Vec<usize>)> {
let mut d = Document::from_text("Filler");
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
p.props.space_before = Some(push);
}
let mut outer = Table::new(1, cells.len(), 440.0);
for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) {
let mut inner = Table::new(1, 1, 200.0);
inner.rows[0].props.cant_split = cant_split;
inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested);
cell.blocks = one_line_paras("Prefix", prefix);
cell.blocks.push(Arc::new(Block::Table(inner)));
cell.blocks.push(para_block(Paragraph::with_text("", Default::default())));
}
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
let mut c = LayoutCache::new();
let l = layout(&d, &mut c, &LayoutOptions::default());
let count = |cell: u32, nested: bool| -> Vec<usize> {
l.pages
.iter()
.map(|pg| {
pg.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, l0, l1, .. } => {
let p = &path.0;
let hit = if nested {
p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0]
} else {
p.len() == 4 && p[..3] == [1, 0, cell]
};
hit.then_some(l1 - l0)
}
_ => None,
})
.sum()
})
.collect()
};
(0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect()
}
#[test]
fn nested_cant_split_row_moves_on_with_its_outer_row() {
let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1;
assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}");
assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole");
}
#[test]
fn the_outer_row_splits_above_a_nested_cant_split_row() {
// About 22 lines of room: the nested row (lines 10 to 40) runs through it.
let cell = &lines_per_page(340.0, &[(10, 30)], true)[0];
assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}");
assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}");
}
#[test]
fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() {
// Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the
// cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too.
let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true);
assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}");
assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}");
assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}");
}
#[test]
fn nested_cant_split_row_taller_than_a_page_still_splits() {
let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1;
assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}");
assert_eq!(pages.iter().sum::<usize>(), 60, "every line is placed once: {pages:?}");
assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}");
}
#[test]
fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() {
let mut nested = Table::new(40, 1, 200.0);
nested.props.cell_margins = Some([0.0; 4]);
for (i, row) in nested.rows.iter_mut().enumerate() {
row.props.cant_split = true;
row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2);
}
let mut outer = Table::new(1, 1, 400.0);
// A bottom margin: the part left on the page keeps it below the last row there.
outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]);
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
let l = lay_pushed(520.0, outer);
let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]);
assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}");
assert_eq!(pages.iter().sum::<usize>(), 80, "every line is placed once: {pages:?}");
for r in 0..40u32 {
let on: Vec<usize> = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]);
assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}");
}
}
fn empty() -> Arc<Block> {
para_block(Paragraph::with_text("", Default::default()))
}
/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs.
fn kept_nested(lines: usize) -> Arc<Block> {
let mut t = Table::new(1, 1, 200.0);
t.rows[0].props.cant_split = true;
t.rows[0].cells[0].blocks = one_line_paras("Nested", lines);
Arc::new(Block::Table(t))
}
/// Lay out a filler paragraph pushed `push` points down, then `outer`.
fn lay_pushed(push: f32, outer: Table) -> DocLayout {
let mut d = Document::from_text("Filler");
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
p.props.space_before = Some(push);
}
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
let mut c = LayoutCache::new();
layout(&d, &mut c, &LayoutOptions::default())
}
/// The lines on each page of the body paragraphs whose path `hit` picks.
fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec<usize> {
l.pages
.iter()
.map(|pg| {
pg.items
.iter()
.filter_map(|it| match it {
Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0),
_ => None,
})
.sum()
})
.collect()
}
#[test]
fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() {
// Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top,
// where its nested row (lines 2 to 32) runs through the room of about 22 lines.
let mut outer = Table::new(1, 2, 440.0);
outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50);
outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom);
let mut blocks = one_line_paras("Prefix", 2);
blocks.extend([kept_nested(30), empty()]);
outer.rows[0].cells[1].blocks = blocks;
let l = lay_pushed(340.0, outer);
let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]);
assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}");
}
#[test]
fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() {
let mut outer = Table::new(2, 1, 400.0);
outer.rows[0].props.header = true;
outer.rows[0].cells[0].blocks = one_line_paras("Header", 1);
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
let l = lay_pushed(520.0, outer);
let header = per_page(&l, |p| p == [1, 0, 0, 0]);
assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}");
assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]);
}
#[test]
fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() {
let mut outer = Table::new(2, 1, 400.0);
let mut kept = one_line_paras("Kept", 1);
if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) {
p.props.keep_next = Some(true);
}
outer.rows[0].cells[0].blocks = kept;
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
let l = lay_pushed(520.0, outer);
let kept = per_page(&l, |p| p == [1, 0, 0, 0]);
assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}");
}
#[test]
fn a_merged_cell_doesnt_stretch_its_cant_split_row() {
// Row 0 can't split; its first cell is merged down into row 1, which can and runs through
// the room. The cut stays in row 1.
let mut nested = Table::new(2, 2, 300.0);
nested.rows[0].props.cant_split = true;
nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1);
nested.rows[0].cells[1].blocks = one_line_paras("Short", 1);
nested.rows[1].cells[1].blocks = one_line_paras("Long", 20);
let mut outer = Table::new(1, 1, 400.0);
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
let l = lay_pushed(520.0, outer);
let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]);
assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}");
}
-1
View File
@@ -208,7 +208,6 @@ fn links_out_of_a_root_are_refused() {
}
/// The names in `dir`, sorted.
#[cfg(unix)]
fn names(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
+21 -44
View File
@@ -166,15 +166,6 @@ impl Exporter<'_> {
}
s.pop();
}
// A turned picture cut at its line's top and bottom.
Draw::Clip { rect, items } => {
let Some(clip) = rect_path(rect) else { return };
s.push_clip_path(&clip, &FillRule::NonZero);
for it in items {
self.rotated_pictures(s, it);
}
s.pop();
}
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| {
@@ -186,40 +177,6 @@ impl Exporter<'_> {
}
}
/// One draw of a paragraph's line, tagged for what it is: text as the paragraph's content
/// (structure entry `idx`), pictures and figures on their own, anything else an artifact.
fn line_item(&mut self, s: &mut Surface, d: &Draw, idx: usize) {
match d {
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// A picture marked decorative: an artifact screen readers skip.
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
// A rotated inline picture, or a rotated group holding pictures: turned as a
// whole, each picture tagged on its own (see `rotated_pictures`).
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
// A clip (a turned picture cut at its line, an exact-height cell's lines, a canvas's
// members): each draw in it tagged as it would be outside it.
Draw::Clip { rect, items } => {
let Some(clip) = rect_path(rect) else { return };
s.push_clip_path(&clip, &FillRule::NonZero);
for it in items {
self.line_item(s, it, idx);
}
s.pop();
}
// A turned text box's line: still the paragraph's text. A rotated shape
// without text falls to the artifacts below.
Draw::Rotated { items, .. } if items.iter().any(holds_text) => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
for d in inner {
me.draw(s, d);
}
}),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
}
pub(crate) fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) {
if let Some(c) = self.doc.settings.page_color
&& let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h))
@@ -264,7 +221,27 @@ impl Exporter<'_> {
Placed::Lines { story, path, para, .. } => {
let idx = self.entry(*story, path, para);
for d in &draws {
self.line_item(s, d, idx);
match d {
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// A picture marked decorative: an artifact screen readers skip.
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
// A rotated inline picture, or a rotated group holding pictures: turned as a
// whole, each picture tagged on its own (see `rotated_pictures`).
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
// A turned text box's line: still the paragraph's text. A rotated shape
// without text falls to the artifacts below.
Draw::Rotated { items, .. } if items.iter().any(holds_text) => {
self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d))
}
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
for d in inner {
me.draw(s, d);
}
}),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
}
}
Placed::Image { story, path, off, .. } => {
+2 -3
View File
@@ -5,12 +5,11 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
if alt.trim().is_empty() { kind.noun() } else { alt }
}
/// A picture, or a rotation or clip holding one at any depth (a rotated group's turned members, a
/// turned picture cut at its line).
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
pub(crate) fn holds_picture(d: &Draw) -> bool {
match d {
Draw::Image { .. } => true,
Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture),
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
_ => false,
}
}
-39
View File
@@ -1190,8 +1190,6 @@ fn decorative_pictures_are_artifacts_not_figures() {
let cases = [
("inline", Float::default()),
("rotated inline", Float { rot: 30.0, ..Default::default() }),
// Turned with no room kept for it: drawn cut at its line (a clip), still tagged.
("bare turned inline", Float { rot: 45.0, bare_turn: true, ..Default::default() }),
("floating", floating.clone()),
("rotated floating", Float { rot: 30.0, ..floating }),
];
@@ -1204,43 +1202,6 @@ fn decorative_pictures_are_artifacts_not_figures() {
}
}
/// A row of exact height cuts its cell's lines (one clip around them): text and a picture in
/// it are tagged as they are in a row that fits, the text as the paragraph's, the picture as a
/// Figure with its alt text.
#[test]
fn a_clipped_cell_tags_its_text_and_picture() {
let raw = |exact: bool| {
let mut d = Document::new();
let media = d.add_media(red_right_half_emf(), "emf");
let mut p = Paragraph::with_text("Cell text ", CharProps::default());
let n = p.len();
let obj = InlineObject::Image {
media,
w: 70.0,
h: 70.0,
alt: "A red square".into(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
p.insert_object(n, obj, &CharProps::default()).unwrap();
let mut t = Table::new(1, 1, 300.0);
t.rows[0].cells[0].blocks = vec![para_block(p)];
if exact {
t.rows[0].props.height = Some(30.0);
t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
}
d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))];
String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned()
};
let (fits, cut) = (raw(false), raw(true));
assert!(cut.contains("/Alt(A red square)"), "the picture in the cut cell is a Figure with its alt text");
// The same marked content either way: the cut cell's text is not dropped as an artifact.
assert_eq!(cut.matches("/MCID").count(), fits.matches("/MCID").count());
assert_eq!(cut.matches("/S/P").count(), fits.matches("/S/P").count());
}
/// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the
/// other stays a Figure with its alt text.
#[test]
+1 -13
View File
@@ -842,22 +842,10 @@ fn draw_image(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
}
}
/// Widest path drawn, in device pixels a side: 64 times the largest canvas. The rasteriser
/// flattens a curve into as many points as its size asks for, before clipping, so a path with
/// coordinates of 1e15 (file data in a native document) would take it forever or run out of
/// memory; one this far past every canvas shows nothing a real drawing would.
const MAX_PATH_PIXELS: f64 = 1_048_576.0;
/// Whether `path` under `view` is small enough to draw ([`MAX_PATH_PIXELS`]) and finite.
fn path_extent_ok(view: Affine, path: &kurbo::BezPath) -> bool {
let b = view.transform_rect_bbox(path.bounding_box());
path.is_finite() && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_PATH_PIXELS)
}
fn draw_path(ctx: &mut RenderContext, fr: &Frame, it: &Draw) {
let Draw::Path { segs, fill, stroke, stroke_width, dash, cap } = it else { return };
let path = wordcraft_layout::display::seg_path(segs);
if path.elements().is_empty() || !path_extent_ok(fr.view, &path) {
if path.elements().is_empty() {
return;
}
ctx.set_transform(fr.view);
-15
View File
@@ -1411,18 +1411,3 @@ fn a_line_runs_from_top_left_to_bottom_right() {
let p = shape_path(ShapeKind::Line, kurbo::Rect::new(10.0, 20.0, 30.0, 60.0), None);
assert_eq!(p.elements(), &[kurbo::PathEl::MoveTo((10.0, 20.0).into()), kurbo::PathEl::LineTo((30.0, 60.0).into())]);
}
#[test]
fn paths_far_past_every_canvas_are_not_flattened() {
let line = |x: f64| {
let mut p = kurbo::BezPath::new();
p.move_to((0.0, 0.0));
p.curve_to((x, 0.0), (x, x), (0.0, x));
p
};
assert!(path_extent_ok(Affine::IDENTITY, &line(500.0)));
assert!(path_extent_ok(Affine::scale(4.0), &line(200_000.0)), "a large page at a high zoom");
assert!(!path_extent_ok(Affine::IDENTITY, &line(1e15)));
assert!(!path_extent_ok(Affine::scale(1e12), &line(10.0)));
assert!(!path_extent_ok(Affine::IDENTITY, &line(f64::NAN)));
}
+13 -18
View File
@@ -184,28 +184,23 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
});
});
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(0, 30))).show(ui, |ui| {
// The side padding sits inside the pages, so the scroll area reaches the window's edge.
let pad = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 0));
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
// The Print page fills the pane and scrolls its settings and its preview itself (#1156).
if app.ui.backstage_page == "print" {
pad.show(ui, |ui| crate::rtl::root(ui, |ui| export_page(app, ui)));
crate::rtl::root(ui, |ui| export_page(app, ui));
return;
}
// Full width, so the bar sits at the window's edge and the wheel works over the whole page (#5282).
egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| {
pad.show(ui, |ui| {
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
"home" => home_page(app, ui),
"new" => new_page(app, ui),
"open" => open_page(app, ui),
"saveAs" => save_as_page(app, ui),
"info" => info_page(app, ui),
"recover" => recovery_page(app, ui),
"print" | "export" => export_page(app, ui),
"options" => crate::options::page(app, ui),
_ => home_page(app, ui),
})
egui::ScrollArea::vertical().show(ui, |ui| {
crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() {
"home" => home_page(app, ui),
"new" => new_page(app, ui),
"open" => open_page(app, ui),
"saveAs" => save_as_page(app, ui),
"info" => info_page(app, ui),
"recover" => recovery_page(app, ui),
"print" | "export" => export_page(app, ui),
"options" => crate::options::page(app, ui),
_ => home_page(app, ui),
})
});
});
-52
View File
@@ -1054,55 +1054,3 @@ fn backstage_save_as_sidebar_opens_save_as_page() {
h.run_steps(4);
assert_eq!(h.state().ui.backstage_page, "saveAs");
}
/// Scrolls the File page with the wheel at `x` and returns doc0.docx's top before and after,
/// plus the rectangle of the page's vertical scrollbar.
fn file_page_wheel_at(x: f32) -> (f32, f32, egui::Rect) {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
a.ui.recent = (0..12).map(|i| format!("/docs/doc{i}.docx")).collect();
a.run("ui.backstage", json!({"value": true, "page": "home"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 500.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
let bar = h
.query_all_by_role(egui::accesskit::Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the File page's vertical scrollbar");
let before = h.get_by_label("doc0.docx").rect();
h.event(egui::Event::PointerMoved(egui::pos2(x, 300.0)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -200.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(4);
(before.min.y, h.get_by_label("doc0.docx").rect().min.y, bar)
}
/// #5282: the File page scrolls with the wheel over its right side.
#[test]
fn the_file_page_scrolls_with_the_wheel_over_its_right_side() {
let (before, after, _) = file_page_wheel_at(1300.0);
assert!(after < before - 1.0, "expected doc0.docx to scroll up: before={before}, after={after}");
}
/// #5282: the scroll area reaches the window's right edge, so its bar sits there and the wheel
/// works there too.
#[test]
fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() {
// The harness keeps an 8 point margin, so the window edge is at x=1432.
let (before, after, bar) = file_page_wheel_at(1428.0);
assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}");
assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}");
}
-1
View File
@@ -15,7 +15,6 @@ use crate::WordApp;
use crate::theme::{Tokens, regular, semibold};
mod balloons;
mod column_ruler;
mod context_menu;
#[cfg(test)]
pub(crate) use context_menu::issue_heading;
-343
View File
@@ -1,343 +0,0 @@
//! The caret section's text columns on the horizontal ruler (#5197): the space between each two
//! columns is shaded like the margins, and its edges are dragged to change the columns' widths
//! and spacing, through `layout.columns`. As in Word for Mac, the space has no grip of its own
//! in the middle: only its edges drag.
use serde_json::Value;
use wordcraft_doc::section::SectionProps;
use wordcraft_doc::{Pos, StoryRef};
use super::{DocLayout, Rect, Sense, Tokens, Ui, WordApp, json, pos2};
/// Which edge of the space between two columns a drag grabs, in reading order: the end of the
/// column before it or the start of the column after it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum GapGrip {
End,
Start,
}
/// The narrowest column a drag leaves: a quarter inch, as the Columns dialog allows.
const MIN_COLUMN: f32 = 18.0;
/// The widest space a drag leaves between two columns, as the Columns dialog allows.
const MAX_SPACE: f32 = 300.0;
/// The caret section's columns on a page, for the ruler.
pub(super) struct ColumnGaps {
/// The caret's section.
sect: SectionProps,
/// The page x of the section's text area's left edge.
origin: f32,
/// Each space between two neighbouring columns in reading order: its (left, right) page x.
gaps: Vec<(f32, f32)>,
}
/// The spaces between the caret section's columns on page `pi`: none outside the main text or
/// Print Layout (where text runs in one column), in a table (whose own markers take the ruler),
/// in a section of one column, for a selection across sections (a drag would set the columns of
/// each) or when the caret isn't on that page (the ruler stays put while the whole document is
/// selected).
pub(super) fn column_gaps(app: &WordApp, layout: &DocLayout, pi: usize) -> Option<ColumnGaps> {
let sel = &app.session.sel;
if sel.focus.story != StoryRef::Body || sel.anchor.story != StoryRef::Body {
return None;
}
if app.session.view.mode != wordcraft_layout::ViewMode::Print || app.session.view.read_mode {
return None;
}
if sel.focus.path.cell().is_some() || crate::table_ruler::text_area(app, layout, pi).is_some() {
return None;
}
let doc = &app.session.doc;
let section = |p: &Pos| doc.section_index_of(p.path.0.first().copied().unwrap_or(0) as usize);
if section(&sel.anchor) != section(&sel.focus) {
return None;
}
if layout.caret_on(&sel.focus, pi).map(|c| c.page) != Some(pi) {
return None;
}
let sect = wordcraft_engine::cmd::page::sect(&app.session);
if sect.columns.count <= 1 {
return None;
}
let page = layout.pages.get(pi)?;
// A continuous section can change the margins on the page: its columns start at its own
// margin, moved as the page's text area is (on an even page of a mirrored layout, #2914).
let sections = app.session.doc.sections();
let (_, page_sect) = sections.get(page.section)?;
let origin = page.body.x + sect.margin_left + sect.gutter - page_sect.margin_left - page_sect.gutter;
let gaps = gaps_of(&sect).into_iter().map(|(l, r)| (origin + l, origin + r)).collect();
Some(ColumnGaps { sect, origin, gaps })
}
/// Each space between two neighbouring columns of `s` in reading order, as (left, right) points
/// from the text area's left edge.
fn gaps_of(s: &SectionProps) -> Vec<(f32, f32)> {
s.column_boxes()
.windows(2)
.filter_map(|w| match w {
// Leftwards in a right-to-left section.
[(ax, aw), (bx, bw)] => Some(if bx >= ax { (ax + aw, *bx) } else { (bx + bw, *ax) }),
_ => None,
})
.collect()
}
/// Shades the spaces between the columns on the ruler `bar`, over the white text area.
pub(super) fn draw(painter: &egui::Painter, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, t: &Tokens) {
for (l, r) in &cg.gaps {
let (sl, sr) = (x0 + l * scale, x0 + r * scale);
if sr > sl {
painter.rect_filled(Rect::from_min_max(pos2(sl, bar.min.y), pos2(sr, bar.max.y)), 0.0, t.ruler_margin);
}
}
}
/// The drag zones of the spaces between the columns: each of their edges, none over a tab
/// stop's marker (`tabs`), which keeps its own drag. Registered after the tab stops (whose
/// set-a-stop zone spans the text area) and before the indent markers, which keep the pointer
/// where they overlap. Columns the command wouldn't take back get no zones.
pub(super) fn handles(app: &mut WordApp, ui: &Ui, cg: &ColumnGaps, bar: Rect, x0: f32, scale: f32, tabs: &[Rect]) {
if !draggable(&cg.sect) {
return;
}
let rtl = cg.sect.rtl;
for (gap, (l, r)) in cg.gaps.iter().enumerate() {
let (sl, sr) = (x0 + l * scale, x0 + r * scale);
// Left to right, the space's left edge ends the column before it; right to left, it
// starts the column after it.
let (left, right) = if rtl { (GapGrip::Start, GapGrip::End) } else { (GapGrip::End, GapGrip::Start) };
// A narrow space is split at its middle, so both edges can be grabbed.
let mid = (sl + sr) / 2.0;
let zones = [
(left, Rect::from_min_max(pos2(sl - 3.0, bar.min.y), pos2((sl + 3.0).min(mid), bar.max.y))),
(right, Rect::from_min_max(pos2((sr - 3.0).max(mid), bar.min.y), pos2(sr + 3.0, bar.max.y))),
];
for (grip, zone) in zones {
if tabs.iter().any(|t| t.intersects(zone)) {
continue;
}
let r =
ui.interact(zone, ui.scope_id().with(("ruler_column", gap, grip)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
let r = if r.dragged() { r } else { r.on_hover_text(tl!("Column Edge")) };
if r.dragged()
&& let Some(pp) = r.interact_pointer_pos()
{
let v = (pp.x - x0) / scale - cg.origin;
let at = if rtl { cg.sect.text_width() - v } else { v };
let Some(params) = column_drag(&cg.sect, gap, grip, at) else { continue };
if !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run_keep_view("layout.columns", params);
}
}
}
}
/// The `layout.columns` parameters after dragging `grip` of the space after column `gap` (in
/// reading order) to `at` points from the text area's start edge (its right edge in a
/// right-to-left section). Equal columns stay equal, so every space changes, as in Word for Mac;
/// between unequal columns only the column at that edge and the space change. Columns stay at
/// least a quarter inch wide and the space 0–300 pt, snapped to 1/16 inch. `None` when there is
/// no such space or `at` is junk.
pub(crate) fn column_drag(s: &SectionProps, gap: usize, grip: GapGrip, at: f32) -> Option<Value> {
let n = s.columns.count as usize;
if !at.is_finite() || !draggable(s) || gap + 1 >= n {
return None;
}
let tw = s.text_width();
let sep = s.columns.separator;
if s.columns.widths.len() != n {
// Column k is w wide and starts at k·(w + space), w = (tw − (n − 1)·space) / n: solved
// for the space that puts the edge at `at`.
let (i, nf) = (gap as f32, n as f32);
let space = match grip {
GapGrip::End => ((i + 1.0) * tw - at * nf) / (nf - 1.0 - i),
_ => at * nf / (i + 1.0) - tw,
};
let most = ((tw - MIN_COLUMN * nf) / (nf - 1.0)).clamp(0.0, MAX_SPACE);
// Snapped, and at the limit the last 1/16 inch below it.
let space = (space / 4.5).round() * 4.5;
let space = if space > most { (most / 4.5).floor() * 4.5 } else { space.max(0.0) };
return Some(json!({"count": n, "space": space, "separator": sep, "apply": "section"}));
}
// Unequal columns: (width, space after) each.
let mut cols = s.columns.widths.clone();
let start: f32 = cols.iter().take(gap).map(|(w, sp)| w + sp).sum();
let (&(w0, sp), &(w1, sp1)) = (cols.get(gap)?, cols.get(gap + 1)?);
let at = (at / 4.5).round() * 4.5;
let (end0, start1, end1) = (start + w0, start + w0 + sp, start + w0 + sp + w1);
let (w0, sp, w1) = match grip {
GapGrip::End => {
// `max` and `min` rather than `clamp`, which panics on bounds from a hostile file.
let e = at.min(start1.max(start + MIN_COLUMN)).max((start + MIN_COLUMN).max(start1 - MAX_SPACE));
(e - start, start1 - e, w1)
}
GapGrip::Start => {
let b = at.min((end1 - MIN_COLUMN).min(end0 + MAX_SPACE)).max(end0);
(w0, b - end0, end1 - b)
}
};
if let Some(c) = cols.get_mut(gap) {
*c = (w0, sp);
}
if let Some(c) = cols.get_mut(gap + 1) {
*c = (w1, sp1);
}
Some(json!({"widths": cols.iter().map(|(w, sp)| [*w, *sp]).collect::<Vec<_>>(), "separator": sep, "apply": "section"}))
}
/// Whether `layout.columns` takes the section's columns back after a drag: 2–12 columns, unequal
/// ones each at least a quarter inch wide with 0–300 pt after it and no wider together than the
/// text. A file can hold others (up to 45 columns, any widths); their spaces are shown, not
/// dragged.
fn draggable(s: &SectionProps) -> bool {
let n = s.columns.count as usize;
let widths = &s.columns.widths;
if !(2..=12).contains(&n) {
return false;
}
if widths.len() != n {
return true;
}
// The last column has no space after it (the command drops one).
let space = |i: usize, sp: f32| if i + 1 == n { 0.0 } else { sp };
let total: f32 = widths.iter().enumerate().map(|(i, (w, sp))| w + space(i, *sp)).sum();
total <= s.text_width() + 1.0 && widths.iter().enumerate().all(|(i, (w, sp))| *w >= MIN_COLUMN && (0.0..=MAX_SPACE).contains(&space(i, *sp)))
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::section::Columns;
/// Letter paper with inch margins: 468 pt of text.
fn letter(columns: Columns) -> SectionProps {
SectionProps { page_w: 612.0, page_h: 792.0, margin_left: 72.0, margin_right: 72.0, gutter: 0.0, columns, ..Default::default() }
}
fn equal(count: u32, space: f32) -> SectionProps {
letter(Columns { count, space, ..Default::default() })
}
fn widths(v: &Value) -> Vec<(f32, f32)> {
v["widths"].as_array().into_iter().flatten().map(|c| (c[0].as_f64().unwrap_or(0.0) as f32, c[1].as_f64().unwrap_or(0.0) as f32)).collect()
}
/// #5197: two equal columns half an inch apart leave a space from 216 to 252 pt.
#[test]
fn the_spaces_between_columns_are_where_the_columns_are_not() {
assert_eq!(gaps_of(&equal(2, 36.0)), vec![(216.0, 252.0)]);
assert_eq!(gaps_of(&equal(3, 36.0)), vec![(132.0, 168.0), (300.0, 336.0)]);
assert!(gaps_of(&equal(1, 36.0)).is_empty(), "one column, no space");
// Right to left, the first column is on the right: the same spaces, read leftwards.
let rtl = SectionProps { rtl: true, ..equal(3, 36.0) };
assert_eq!(gaps_of(&rtl), vec![(300.0, 336.0), (132.0, 168.0)]);
let unequal = letter(Columns { count: 2, space: 18.0, widths: vec![(100.0, 18.0), (350.0, 0.0)], ..Default::default() });
assert_eq!(gaps_of(&unequal), vec![(100.0, 118.0)]);
}
/// #5197: dragging an edge of the space between equal columns keeps them equal and changes
/// the spacing, so the dragged edge lands at the pointer.
#[test]
fn dragging_an_edge_between_equal_columns_changes_the_spacing() {
let s = equal(2, 36.0);
// The first column's end, from 216 to 207: 9 pt more space each side, 18 in all.
let v = column_drag(&s, 0, GapGrip::End, 207.0).unwrap_or_default();
assert_eq!(v, json!({"count": 2, "space": 54.0, "separator": false, "apply": "section"}));
// The second column's start, from 252 to 261: the same.
assert_eq!(column_drag(&s, 0, GapGrip::Start, 261.0).unwrap_or_default()["space"], 54.0);
// Past the middle the space closes; it never goes negative.
assert_eq!(column_drag(&s, 0, GapGrip::End, 400.0).unwrap_or_default()["space"], 0.0);
// At most 300 pt: 297, the last 1/16 inch below it.
assert_eq!(column_drag(&s, 0, GapGrip::End, 0.0).unwrap_or_default()["space"], 297.0);
// And it never leaves a column narrower than a quarter inch.
let narrow = equal(12, 0.0);
let most = (468.0 - 18.0 * 12.0) / 11.0;
let space = column_drag(&narrow, 0, GapGrip::End, 0.0).unwrap_or_default()["space"].as_f64().unwrap_or(0.0) as f32;
assert!(space <= most, "{space} leaves every column a quarter inch ({most})");
assert_eq!(space % 4.5, 0.0, "{space} is on the 1/16 inch grid at the limit too");
// Three columns: the second space's start edge, from 336 to 345.
let s = equal(3, 36.0);
assert_eq!(column_drag(&s, 1, GapGrip::Start, 345.0).unwrap_or_default()["space"], 49.5);
}
/// #5197: between unequal columns only the column at the dragged edge and the space change.
#[test]
fn dragging_between_unequal_columns_changes_its_neighbours() {
let s = letter(Columns { count: 3, space: 18.0, widths: vec![(100.0, 18.0), (200.0, 18.0), (132.0, 0.0)], ..Default::default() });
// The first column's end, 100 → 90: it narrows and the space after it widens.
assert_eq!(widths(&column_drag(&s, 0, GapGrip::End, 90.0).unwrap_or_default()), vec![(90.0, 28.0), (200.0, 18.0), (132.0, 0.0)]);
// The third column's start, 336 → 300, back into the second column: it stops at the
// second column's end (318), the space closes and the third column takes it.
assert_eq!(widths(&column_drag(&s, 1, GapGrip::Start, 300.0).unwrap_or_default()), vec![(100.0, 18.0), (200.0, 0.0), (150.0, 0.0)]);
// The second column's start, 118 → 1000: the column never gets narrower than a quarter
// inch.
assert_eq!(widths(&column_drag(&s, 0, GapGrip::Start, 1000.0).unwrap_or_default()), vec![(100.0, 200.0), (18.0, 18.0), (132.0, 0.0)]);
}
/// Junk positions and spaces that aren't there change nothing.
#[test]
fn column_drags_ignore_junk() {
let s = equal(2, 36.0);
assert!(column_drag(&s, 0, GapGrip::End, f32::NAN).is_none());
assert!(column_drag(&s, 1, GapGrip::End, 100.0).is_none(), "two columns have one space");
assert!(column_drag(&equal(1, 36.0), 0, GapGrip::Start, 100.0).is_none());
assert!(column_drag(&equal(13, 0.0), 0, GapGrip::End, 10.0).is_none(), "more columns than the command takes");
// Unequal widths from a file that the command wouldn't take back aren't dragged.
let narrow = letter(Columns { count: 2, widths: vec![(5.0, 0.0), (463.0, 0.0)], ..Default::default() });
assert!(column_drag(&narrow, 0, GapGrip::End, 30.0).is_none(), "a column under a quarter inch");
let wide = letter(Columns { count: 2, widths: vec![(100.0, 400.0), (50.0, 0.0)], ..Default::default() });
assert!(column_drag(&wide, 0, GapGrip::End, 90.0).is_none(), "a space over 300 pt");
let over = letter(Columns { count: 2, widths: vec![(300.0, 18.0), (300.0, 0.0)], ..Default::default() });
assert!(column_drag(&over, 0, GapGrip::End, 290.0).is_none(), "wider than the text");
let last = letter(Columns { count: 2, widths: vec![(200.0, 18.0), (250.0, 999.0)], ..Default::default() });
assert!(column_drag(&last, 0, GapGrip::End, 190.0).is_some(), "the last column's space after it doesn't count");
}
/// Through the command: a drag's result is applied to the caret's section, keeps the line
/// between columns, and is one undo step.
#[test]
fn a_column_drag_sets_the_sections_columns() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one two three")), Default::default());
app.run("layout.columns", json!({"count": 2, "separator": true})).unwrap();
let before = wordcraft_engine::cmd::page::sect(&app.session);
// The first column's end, 9 pt to the left: 18 pt more space, the columns still equal.
let end = before.column_boxes()[0].0 + before.column_boxes()[0].1;
let params = column_drag(&before, 0, GapGrip::End, end - 9.0).unwrap();
app.run("layout.columns", params).unwrap();
let after = wordcraft_engine::cmd::page::sect(&app.session);
assert_eq!(after.columns.count, 2);
assert!(after.columns.widths.is_empty(), "the columns stay equal");
assert_eq!(after.columns.space, before.columns.space + 18.0);
assert!(after.columns.separator, "the line between the columns stays");
let boxes = after.column_boxes();
assert!((boxes[0].1 - (end - 9.0)).abs() < 0.01, "the edge is at the pointer: {boxes:?}");
let laid = app.session.layout();
let page = laid.pages.first().unwrap();
let cg = column_gaps(&app, &laid, 0).unwrap();
assert_eq!(cg.gaps.len(), 1);
assert!((cg.gaps[0].0 - (page.body.x + boxes[0].1)).abs() < 0.01, "the ruler's space starts where the first column ends");
assert!(app.session.undo());
assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).columns, before.columns);
}
/// A drag sets the columns of every section the selection touches, so a selection across
/// sections gets no spaces on the ruler; nor does a page the caret isn't on.
#[test]
fn no_column_spaces_for_a_selection_across_sections_or_another_page() {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one\ntwo")), Default::default());
app.run("caret.docEnd", json!({})).unwrap();
app.run("layout.break", json!({"kind": "nextPage"})).unwrap();
app.run("layout.columns", json!({"count": 2, "apply": "section"})).unwrap();
let laid = app.session.layout();
assert_eq!(laid.pages.len(), 2);
assert!(column_gaps(&app, &laid, 1).is_some(), "the caret's page shows the second section's columns");
assert!(column_gaps(&app, &laid, 0).is_none(), "the first page has one column");
app.run("select.all", json!({})).unwrap();
let laid = app.session.layout();
assert!(column_gaps(&app, &laid, 1).is_none(), "the selection runs into a section of one column");
}
}
-10
View File
@@ -24,11 +24,6 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[
hp.rect_filled(bar, 0.0, t.ruler_margin);
let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y));
hp.rect_filled(text, 0.0, t.ruler);
// The spaces between the caret section's text columns, shaded like the margins (#5197).
let columns = super::column_ruler::column_gaps(app, layout, pi);
if let Some(cg) = &columns {
super::column_ruler::draw(&hp, cg, bar, x0, scale, &t);
}
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|para| app.session.doc.styles.resolve_para(&para.props));
// Graduations in the File › Options unit.
let (unit, div, step) = ruler_scale(app.session.units());
@@ -106,11 +101,6 @@ pub(super) fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[
// spans the text area, so a border marker keeps its drag) and before the indent markers,
// which keep the pointer where they overlap.
crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale);
// Outside a table, the spaces between the text columns, in the same order (#5197).
if let Some(cg) = &columns {
let tabs = crate::ruler_tabs::marker_rects(app, bar, &px, &rp.tabs);
super::column_ruler::handles(app, ui, cg, bar, x0, scale, &tabs);
}
// Dragging the left-indent marker.
let id = ui.scope_id().with("ruler_left");
let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0));
+1 -1
View File
@@ -694,7 +694,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
/// Style sets shown in the Design tab's gallery, in order.
const STYLE_SETS: [&str; 7] = ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"];
/// Width of one gallery thumbnail, points.
pub const STYLE_SET_W: f32 = 56.0;
const STYLE_SET_W: f32 = 56.0;
/// Width the whole gallery takes with the ribbon's 1 pt item spacing.
pub const STYLE_SET_GALLERY_W: f32 = STYLE_SETS.len() as f32 * (STYLE_SET_W + 1.0) - 1.0;
+2 -3
View File
@@ -3,12 +3,11 @@ use super::{CONTENT_H, Rect, Sense, Target, Ui, WordApp, big, group, json, menu_
/// The Design tab's style set gallery in a box of its own, sized from its thumbnails and clipped
/// to it, with a gap on each side: the Colors and Fonts column can't be drawn over, or cut by,
/// the thumbnails (#638). In a right-to-left interface, the thumbnails lay out from the right
/// (#5750). Returns the box and the space the thumbnails used.
/// the thumbnails (#638). Returns the box and the space the thumbnails used.
pub(super) fn style_set_box(app: &mut WordApp, ui: &mut Ui) -> (Rect, Rect) {
ui.add_space(4.0);
let (gallery, _) = ui.allocate_exact_size(vec2(crate::previews::STYLE_SET_GALLERY_W, CONTENT_H), Sense::hover());
let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(crate::rtl::row_layout(ui, egui::Align::Min)));
let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(egui::Layout::left_to_right(egui::Align::Min)));
gui.set_clip_rect(gallery.intersect(ui.clip_rect()));
gui.spacing_mut().item_spacing = vec2(1.0, 1.0);
crate::previews::style_set_gallery(app, &mut gui);
-43
View File
@@ -113,49 +113,6 @@ fn design_style_set_gallery_stays_in_its_box() {
assert!(next_x >= gallery.right() + 8.0 - 0.01, "{next_x} {gallery:?}");
}
/// #5750: in a right-to-left interface, the Design tab's style-set gallery lays out from the
/// right: the first style set ("default") is at the right edge, the remaining thumbnails read to
/// the left, and the next controls start after a gap to the left of the gallery.
#[test]
fn design_style_set_gallery_reads_from_the_right_in_rtl() {
crate::i18n::set_current(crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n"));
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut seen = None;
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 200.0)).build_ui(|ui| {
crate::rtl::root(ui, |ui| {
crate::rtl::horizontal_top(ui, |ui| {
let (gallery, used) = style_set_box(&mut app, ui);
seen = Some((gallery, used, ui.cursor().max.x));
});
});
});
h.run_steps(2);
let tiles: Vec<Rect> = h.ctx.viewport(|v| {
v.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| (w.rect.width() - crate::previews::STYLE_SET_W).abs() < 0.1 && (w.rect.height() - CONTENT_H).abs() < 0.1)
.map(|w| w.rect)
.collect()
});
drop(h);
crate::i18n::set_current(crate::i18n::Lang::EN);
let (gallery, used, next_x) = seen.unwrap();
assert!((gallery.width() - crate::previews::STYLE_SET_GALLERY_W).abs() < 0.01);
assert!(used.left() >= gallery.left() - 0.01, "{used:?} {gallery:?}");
assert!(used.right() <= gallery.right() + 0.01, "{used:?} {gallery:?}");
assert!(next_x <= gallery.left() - 8.0 + 0.01, "{next_x} {gallery:?}");
assert_eq!(tiles.len(), 7, "7 style set tiles");
assert!((tiles[0].right() - gallery.right()).abs() < 0.01, "default tile at the right edge: {tiles:?} {gallery:?}");
assert!((tiles[6].left() - gallery.left()).abs() < 0.01, "minimalist tile at the left edge: {tiles:?} {gallery:?}");
for i in 1..tiles.len() {
assert!(tiles[i].right() <= tiles[i - 1].left(), "tile {i} is to the left of tile {}", i - 1);
}
}
/// #2738: Home › Underline's drop-down: every line sample is a named entry (assistive
/// technology, tooltips) that applies its own style; Words only, None and Underline Color ›
/// Automatic do what they say.
-45
View File
@@ -5837,51 +5837,6 @@ fn check_spelling_survives_restart() {
assert!(second.session.prefs.check_spelling, "the live spelling setting and its saved copy agree");
}
/// With several documents open, turning Check spelling as you type off in one keeps it off in the
/// others, and the next run starts with it off whichever document was active when the settings
/// were saved (#5740: it came back on every time the file was opened again).
#[test]
fn check_spelling_off_survives_a_switch_and_a_restart() {
let mut first = app();
let a = first.session.document_id();
first.run("file.new", json!({})).unwrap();
first.run("file.options", json!({"checkSpelling": false})).unwrap();
first.session.activate(a).unwrap();
assert!(!first.session.view.proofing, "off in the other document too");
let saved = serde_json::to_vec(&first.prefs()).unwrap();
let mut second = app();
second.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert!(!second.session.view.proofing && !second.session.prefs.check_spelling);
}
/// The same across windows: Check spelling as you type turned off in a second window on a document
/// stays off when the first window's own view of it comes back, in a window on another document,
/// back in the second window, and in the saved settings (#5740).
#[test]
fn check_spelling_off_in_a_second_window_stays_off_in_every_window() {
let mut a = app();
let first = a.windows.shown();
a.new_window();
let second = a.windows.shown();
assert_ne!(first, second, "View › New Window opened a window with its own view");
a.run("file.options", json!({"checkSpelling": false})).unwrap();
assert!(a.enter_window(first));
assert!(!a.session.view.proofing, "the first window's own view, put back, follows the setting");
a.run("file.new", json!({})).unwrap();
assert!(!a.session.view.proofing, "a new document's window");
assert!(a.enter_window(second));
assert!(!a.session.view.proofing, "back in the second window");
assert!(a.enter_window(first));
assert!(!a.session.view.proofing);
let saved = serde_json::to_vec(&a.prefs()).unwrap();
let mut b = app();
b.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert!(!b.session.view.proofing && !b.session.prefs.check_spelling, "saved off, whichever window was drawn");
}
#[test]
fn prefs_without_user_name_keep_default() {
let mut a = app();

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