mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f7052b116 | ||
|
|
7634553900 | ||
|
|
cd4f755dcf | ||
|
|
c4d5f70c1d | ||
|
|
de4ca29309 | ||
|
|
b8a7e2acdb | ||
|
|
f0fb31c0c9 | ||
|
|
4cd6ffa564 | ||
|
|
52934ef110 | ||
|
|
3291be0fbf | ||
|
|
5dfb3acf15 | ||
|
|
4259dd1b2e | ||
|
|
5c0a62a736 | ||
|
|
855a0fe9bd | ||
|
|
5f1db14954 | ||
|
|
b016ecc26c | ||
|
|
625f598b64 | ||
|
|
fa791d29b2 | ||
|
|
796b39dde6 | ||
|
|
33fa77a892 | ||
|
|
efd476cbc7 | ||
|
|
d3fcf10bcc | ||
|
|
b9936609dc | ||
|
|
7152143e45 | ||
|
|
c40719f431 | ||
|
|
6d0bd73127 | ||
|
|
cd81d5da49 | ||
|
|
b7c2fe8dff | ||
|
|
586f85df96 | ||
|
|
bd8bc96cb5 | ||
|
|
7830333c34 | ||
|
|
e349439bb6 | ||
|
|
4c8c1f1268 | ||
|
|
caeb87c754 | ||
|
|
f0ef105bf5 |
Generated
+13
-1
@@ -1158,10 +1158,21 @@ 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=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7098,6 +7109,7 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+40
-1
File diff suppressed because one or more lines are too long
@@ -17,7 +17,9 @@ 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.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# 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" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `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;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ 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")]
|
||||
|
||||
@@ -76,7 +76,8 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
// 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;
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# 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
|
||||
@@ -197,3 +201,6 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -772,6 +772,10 @@ 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).
|
||||
@@ -819,15 +823,18 @@ 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 = pa.props.clone();
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.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.
|
||||
|
||||
@@ -186,6 +186,11 @@ 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.
|
||||
@@ -241,6 +246,7 @@ 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
|
||||
@@ -250,6 +256,23 @@ 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(°) || (225.0..315.0).contains(°)) { ((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 })
|
||||
|
||||
@@ -27,7 +27,8 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,6 +26,67 @@ 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)]
|
||||
@@ -391,6 +452,9 @@ 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>,
|
||||
@@ -462,6 +526,7 @@ 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 {
|
||||
|
||||
@@ -137,9 +137,12 @@ 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 at (super/subscript shrink to ~2/3).
|
||||
/// 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).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -453,6 +456,32 @@ 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]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_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,6 +37,11 @@ fn 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 {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
}
|
||||
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
// 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 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));
|
||||
|
||||
@@ -260,6 +260,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -337,6 +337,15 @@ 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(
|
||||
|
||||
@@ -183,6 +183,41 @@ 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
|
||||
|
||||
@@ -350,6 +350,7 @@ 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
|
||||
@@ -405,11 +406,13 @@ 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.list.iter().find(|r| r.id == id)
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -547,6 +550,22 @@ 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])]);
|
||||
|
||||
@@ -55,6 +55,8 @@ 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)
|
||||
|
||||
@@ -5,14 +5,15 @@ 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, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
ParaProps, ParagraphTextDirection, 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, u32_of};
|
||||
|
||||
@@ -145,6 +146,29 @@ 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())
|
||||
@@ -226,6 +250,8 @@ 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() {
|
||||
@@ -234,7 +260,11 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
// 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 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
|
||||
@@ -338,6 +368,7 @@ 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)),
|
||||
|
||||
@@ -41,6 +41,7 @@ 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 {
|
||||
@@ -106,7 +107,11 @@ 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.
|
||||
|
||||
@@ -224,6 +224,15 @@ 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.
|
||||
|
||||
@@ -83,13 +83,14 @@ 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).
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
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 = toc;
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +98,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[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
// 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) };
|
||||
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"));
|
||||
|
||||
@@ -287,6 +287,9 @@ 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());
|
||||
}
|
||||
|
||||
@@ -86,11 +86,28 @@ 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(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
&& (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 { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -298,8 +298,9 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
// 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) };
|
||||
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)]);
|
||||
|
||||
+114
-6
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
|
||||
"?"
|
||||
}
|
||||
|
||||
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
|
||||
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
|
||||
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
|
||||
|
||||
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
|
||||
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
|
||||
/// for Strict documents (#3548):
|
||||
///
|
||||
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
|
||||
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
|
||||
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
|
||||
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
|
||||
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
|
||||
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
|
||||
/// schema's name) and from Word's `wne:txbxContent`.
|
||||
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
|
||||
match prefix_for(uri, extra) {
|
||||
"wp" => drawing_prefix(local, parent),
|
||||
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
|
||||
p => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
|
||||
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
|
||||
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
|
||||
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
|
||||
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
|
||||
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
|
||||
match (parent, local) {
|
||||
(_, "wsp") => "wps",
|
||||
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
|
||||
(_, "wpc") => "wpc",
|
||||
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
|
||||
(Some("wps:txbx"), "txbxContent") => "w",
|
||||
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
|
||||
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
|
||||
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
|
||||
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
|
||||
_ => "wp",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
|
||||
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
|
||||
fn requirement_prefix(uri: &[u8]) -> &'static str {
|
||||
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
let el_ns = match &res {
|
||||
ResolveResult::Bound(ns) => Some(ns.as_ref()),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
return Err(DocxError::Limit("too many XML elements".into()));
|
||||
}
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
|
||||
let name = match el_prefix {
|
||||
Some(p) => format!("{p}:{local}"),
|
||||
let name = match el_ns {
|
||||
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
|
||||
None => local,
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
@@ -419,15 +462,22 @@ 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
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
// 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"));
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -593,6 +643,42 @@ mod tests {
|
||||
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
|
||||
}
|
||||
|
||||
/// Every element name in `e`, depth first.
|
||||
fn names(e: &El, out: &mut Vec<String>) {
|
||||
out.push(e.name.clone());
|
||||
for c in e.els() {
|
||||
names(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_drawing_names_read_as_word_2010_names() {
|
||||
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
|
||||
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
|
||||
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
|
||||
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
|
||||
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
|
||||
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
|
||||
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
|
||||
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
|
||||
</a:graphicData></a:graphic></d:anchor>
|
||||
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
|
||||
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
|
||||
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
|
||||
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
|
||||
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
|
||||
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
|
||||
assert_eq!(got.join(" "), want);
|
||||
// The Transitional namespaces read as before.
|
||||
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
|
||||
let mut got = Vec::new();
|
||||
names(&parse(xml).unwrap(), &mut got);
|
||||
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
|
||||
// The relationship namespace, and VML's `o:relid`.
|
||||
@@ -645,6 +731,28 @@ 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; ` ` 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 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>"#;
|
||||
|
||||
@@ -342,3 +342,57 @@ 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, §_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}");
|
||||
}
|
||||
|
||||
@@ -3947,6 +3947,33 @@ 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() {
|
||||
@@ -3956,6 +3983,15 @@ 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() {
|
||||
@@ -4354,6 +4390,54 @@ 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""#;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
@@ -35,6 +36,7 @@ mod section_page_number_type;
|
||||
mod section_text_direction;
|
||||
mod section_valign;
|
||||
mod settings_merge;
|
||||
mod strict_drawings;
|
||||
mod text_alignment;
|
||||
mod text_boxes;
|
||||
mod toc_ranges;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -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, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, 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,6 +195,7 @@ 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,6 +18,25 @@ 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();
|
||||
@@ -59,3 +78,52 @@ 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(¶s[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}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
|
||||
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
|
||||
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
|
||||
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
|
||||
//! drawing is written once and filled in with either set of namespaces.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use wordcraft_doc::{Document, InlineObject, PartKind};
|
||||
|
||||
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
|
||||
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
|
||||
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
const TRANSITIONAL_RELS: &str = 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>"#;
|
||||
|
||||
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
|
||||
/// text box content element.
|
||||
struct Names {
|
||||
ns: &'static str,
|
||||
rels: &'static str,
|
||||
shape: &'static str,
|
||||
group: &'static str,
|
||||
canvas: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
|
||||
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
|
||||
/// The Strict schema's own names: the text box content in `wp:` too.
|
||||
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
|
||||
/// The same drawings in a Transitional document.
|
||||
const TRANSITIONAL: Names =
|
||||
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
|
||||
|
||||
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in entries {
|
||||
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn solid(rgb: &str) -> String {
|
||||
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
|
||||
}
|
||||
|
||||
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
|
||||
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
|
||||
let s = n.shape;
|
||||
let connector = prst.contains("Connector");
|
||||
let nv = if connector {
|
||||
format!("<{s}:cNvCnPr/>")
|
||||
} else if text.is_some() {
|
||||
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
|
||||
} else {
|
||||
format!("<{s}:cNvSpPr/>")
|
||||
};
|
||||
let fill = if connector { String::new() } else { solid(fill) };
|
||||
let line =
|
||||
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
|
||||
let txbx = match text {
|
||||
Some(t) => {
|
||||
let c = n.content;
|
||||
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
|
||||
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
|
||||
/// shape and a group, each 1 in × ½ in.
|
||||
fn package(n: &Names) -> Vec<u8> {
|
||||
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
|
||||
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
|
||||
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
|
||||
let full = (0, 0, 914_400, 457_200);
|
||||
let half = (0, 0, 457_200, 457_200);
|
||||
let g = n.group;
|
||||
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
|
||||
format!(
|
||||
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
|
||||
solid("F39C12")
|
||||
)
|
||||
};
|
||||
let group = format!(
|
||||
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
|
||||
group_xfrm(full),
|
||||
shape(n, "ellipse", half, "28B463", None),
|
||||
group_xfrm((457_200, 0, 457_200, 457_200)),
|
||||
shape(n, "triangle", half, "8E44AD", None),
|
||||
);
|
||||
let c = n.canvas;
|
||||
let canvas = format!(
|
||||
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
|
||||
solid("FDF2E9"),
|
||||
solid("A04000"),
|
||||
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
|
||||
group_xfrm((457_200, 45_720, 365_760, 365_760)),
|
||||
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
|
||||
);
|
||||
let drawings = [
|
||||
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
|
||||
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
|
||||
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
|
||||
anchor(4, 5_029_200, WPG, &group),
|
||||
anchor(5, 6_400_800, WPC, &canvas),
|
||||
];
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
|
||||
n.ns,
|
||||
drawings.concat()
|
||||
);
|
||||
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn objects(d: &Document) -> Vec<InlineObject> {
|
||||
d.body[0].as_para().unwrap().objects.clone()
|
||||
}
|
||||
|
||||
/// The text of each text box story, in reading order.
|
||||
fn box_texts(d: &Document) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
|
||||
ids.iter()
|
||||
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
|
||||
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_read_like_their_transitional_twins() {
|
||||
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
|
||||
let want = objects(&transitional);
|
||||
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
|
||||
// nested group is flattened into it, and a canvas with its background.
|
||||
assert_eq!(want.len(), 5, "{want:#?}");
|
||||
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
|
||||
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
|
||||
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
|
||||
assert!(
|
||||
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
|
||||
"{:?}",
|
||||
want[4]
|
||||
);
|
||||
assert_eq!(box_texts(&transitional), ["Strict text box"]);
|
||||
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
|
||||
let strict = wordcraft_docx::read(&package(names)).unwrap();
|
||||
assert_eq!(objects(&strict), want, "{name}");
|
||||
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_shapes_are_saved_and_reopened() {
|
||||
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
|
||||
let saved = wordcraft_docx::write(&strict).unwrap();
|
||||
let xml = document_xml(&saved);
|
||||
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
|
||||
assert!(xml.contains(name), "{name} missing from the saved document");
|
||||
}
|
||||
let reopened = wordcraft_docx::read(&saved).unwrap();
|
||||
assert_eq!(objects(&reopened), objects(&strict));
|
||||
assert_eq!(box_texts(&reopened), ["Strict text box"]);
|
||||
}
|
||||
@@ -132,6 +132,15 @@ 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!(
|
||||
@@ -140,13 +149,15 @@ 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(), 6);
|
||||
assert_eq!(bodies.len(), 12);
|
||||
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();
|
||||
|
||||
@@ -270,3 +270,12 @@ 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");
|
||||
}
|
||||
|
||||
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
|
||||
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
|
||||
continue;
|
||||
}
|
||||
atoms.push(lit(c));
|
||||
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
|
||||
// codes above, and literal/regex/wildcard searches, keep exact character matching.
|
||||
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
|
||||
}
|
||||
let filler = match (o.ignore_punct, o.ignore_space) {
|
||||
(true, true) => r"[\p{P} \t\x{A0}]*",
|
||||
|
||||
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".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 (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()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = 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];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +102,9 @@ 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).
|
||||
/// 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).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
@@ -120,7 +122,11 @@ 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) {
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
// 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 id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let InlineObject::Field { instr, locked, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" {
|
||||
if f.name != "SEQ" || in_deletion {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
@@ -149,6 +155,9 @@ 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));
|
||||
|
||||
@@ -793,6 +793,45 @@ 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.
|
||||
|
||||
@@ -54,11 +54,20 @@ 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.
|
||||
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(());
|
||||
// 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(());
|
||||
}
|
||||
}
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
para.mark.ins = None;
|
||||
|
||||
@@ -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 = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_file {
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
if meta.len() > max_file {
|
||||
return None;
|
||||
}
|
||||
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
|
||||
|
||||
@@ -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.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
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.read_page_color = self.prefs.read_page_color;
|
||||
view.follow_prefs(&self.prefs);
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
@@ -5746,6 +5747,39 @@ 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() {
|
||||
@@ -10783,6 +10817,44 @@ 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};
|
||||
@@ -12786,3 +12858,48 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,3 +346,51 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use super::*;
|
||||
|
||||
/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling.
|
||||
#[test]
|
||||
fn keyboard_apostrophe_finds_smart_quoted_typing() {
|
||||
let mut s = s();
|
||||
for ch in "Tau'va".chars() {
|
||||
run(&mut s, "text.insert", json!({"text": ch.to_string()}));
|
||||
}
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0);
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1);
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "Tau’va");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "[Tau’va]");
|
||||
}
|
||||
|
||||
/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact.
|
||||
#[test]
|
||||
fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() {
|
||||
let mut s = s();
|
||||
let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva";
|
||||
run(&mut s, "document.setText", json!({"text": original}));
|
||||
let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true}));
|
||||
assert_eq!(found["count"], 3);
|
||||
let ranges: Vec<_> =
|
||||
found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect();
|
||||
assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]);
|
||||
for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] {
|
||||
let mut exact = super::s();
|
||||
run(&mut exact, "document.setText", json!({"text": original}));
|
||||
let mut params = options;
|
||||
params["text"] = json!("Tau'va");
|
||||
assert_eq!(run(&mut exact, "edit.find", params)["count"], 1);
|
||||
}
|
||||
for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3);
|
||||
assert_eq!(text(&s), "🙂 <Tau'va> <Tau’va> <Tau‘va> Tauʼva");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), original);
|
||||
}
|
||||
@@ -469,3 +469,43 @@ 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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ 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())
|
||||
|
||||
@@ -161,6 +161,17 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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, ¶ms).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}");
|
||||
}
|
||||
+377
-21
@@ -32,12 +32,27 @@ 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) {
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
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 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"];
|
||||
@@ -157,11 +172,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)> {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.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
|
||||
@@ -224,7 +239,10 @@ 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();
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
@@ -356,10 +374,63 @@ 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 {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if family.contains(';') {
|
||||
return list_class(family).thai;
|
||||
}
|
||||
let f = family.trim().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 {
|
||||
@@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
|
||||
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`]).
|
||||
@@ -427,6 +658,10 @@ 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 {
|
||||
@@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
};
|
||||
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);
|
||||
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) };
|
||||
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();
|
||||
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));
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -787,4 +1033,114 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,27 @@ mod tests {
|
||||
|
||||
const 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="xl/workbook.xml"/></Relationships>"#;
|
||||
|
||||
#[test]
|
||||
fn workbook_sheets_find_their_relationships_by_index() {
|
||||
// Every sheet searched every relationship; 4,096 sheets over 20,000 relationships.
|
||||
let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#"<sheet name="S{i}" sheetId="{i}" r:id="rId{i}"/>"#)).collect();
|
||||
let wb = format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#
|
||||
);
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
// A chart sheet sharing a worksheet's id doesn't hide it.
|
||||
rels.push_str(r#"<Relationship Id="rId0" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/c.xml"/>"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(
|
||||
r#"<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>"#
|
||||
));
|
||||
}
|
||||
rels.push_str("</Relationships>");
|
||||
let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap();
|
||||
assert_eq!(names.len(), MAX_SHEETS);
|
||||
assert_eq!(names.first().map(String::as_str), Some("S0"));
|
||||
}
|
||||
|
||||
fn xlsx(sheet1: &str) -> Vec<u8> {
|
||||
let wb = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr/><sheets><sheet name="People" sheetId="1" r:id="rId1"/><sheet name="Chart" sheetId="2" r:id="rId9"/><sheet name="Notes" sheetId="3" r:id="rId2"/></sheets></workbook>"#;
|
||||
let wb_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/sheet1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
|
||||
@@ -83,14 +83,13 @@ fn workbook(pkg: &mut Package<'_>) -> Result<Workbook, String> {
|
||||
}
|
||||
}
|
||||
});
|
||||
let sheets = listed
|
||||
.into_iter()
|
||||
.filter_map(|(name, id)| {
|
||||
// Worksheets only: chart and dialog sheets hold no cells.
|
||||
let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?;
|
||||
Some(SheetRef { name, path: r.target.clone() })
|
||||
})
|
||||
.collect();
|
||||
// Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first
|
||||
// relationship with an id wins), not searched for every sheet.
|
||||
let mut worksheets: HashMap<&str, &str> = HashMap::new();
|
||||
for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) {
|
||||
worksheets.entry(r.id.as_str()).or_insert(r.target.as_str());
|
||||
}
|
||||
let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect();
|
||||
let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone());
|
||||
Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") })
|
||||
}
|
||||
|
||||
@@ -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 = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t));
|
||||
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);
|
||||
@@ -464,14 +464,15 @@ 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.
|
||||
/// `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.
|
||||
fn box_float_table_rect(
|
||||
frame: Option<PageFrame>,
|
||||
width: f32,
|
||||
y: f32,
|
||||
tl: &table::TableLayout,
|
||||
f: &TableFloat,
|
||||
legacy: f32,
|
||||
legacy: Option<f32>,
|
||||
float_tables: &[Rect],
|
||||
) -> (Rect, Rect) {
|
||||
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
||||
@@ -507,18 +508,19 @@ 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 } 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 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 [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 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 {
|
||||
/// 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 {
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
let keeps_text_distance = modern && 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 };
|
||||
|
||||
@@ -637,9 +637,20 @@ 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 rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k));
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -487,8 +487,9 @@ 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`.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
|
||||
/// `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 {
|
||||
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).
|
||||
@@ -503,9 +504,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 the rotated bounds of the frame: undo the rotation.
|
||||
// The room is what the line kept for the turned frame: undo that.
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
|
||||
let (bw, _) = float.spin().extent(w, h);
|
||||
let bw = w + 2.0 * float.line_pad(w, h, modern).0;
|
||||
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)
|
||||
|
||||
@@ -751,7 +751,8 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
}
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
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 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)
|
||||
};
|
||||
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
|
||||
@@ -281,6 +281,17 @@ 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
|
||||
}
|
||||
|
||||
@@ -816,7 +827,8 @@ 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);
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
// 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;
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
|
||||
@@ -130,13 +130,17 @@ 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.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
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)));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !wordcraft_fonts::word::has_named_substitutes(font)
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(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))
|
||||
}
|
||||
{
|
||||
MISSING_ROMAN_FONT
|
||||
} else {
|
||||
|
||||
@@ -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.spin_pad(w * s, h * s);
|
||||
let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT);
|
||||
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,27 +837,36 @@ 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): 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.
|
||||
// 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.
|
||||
let hyphen_breaks: Vec<usize> = text
|
||||
.match_indices('-')
|
||||
.filter_map(|(pos, _)| {
|
||||
let i = pos + 1;
|
||||
text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i)
|
||||
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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !hyphen_breaks.is_empty() {
|
||||
found.extend(hyphen_breaks);
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
found.extend(hyphen_breaks);
|
||||
// The breaks added above come after UAX #14's: put them back in order.
|
||||
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
|
||||
|
||||
@@ -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};
|
||||
use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects};
|
||||
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,9 +10,10 @@ 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, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection,
|
||||
ViewMode, balloons, para, vertical,
|
||||
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, 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.
|
||||
@@ -127,18 +128,35 @@ 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()).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() && !has_inline_content(doc, opts, p)).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 {
|
||||
|
||||
@@ -94,7 +94,62 @@ fn row_keeps_next(row: &table::RowLayout) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
/// 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 {
|
||||
if laid.height <= room + 0.01 {
|
||||
return true;
|
||||
}
|
||||
@@ -104,7 +159,7 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p
|
||||
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;
|
||||
}
|
||||
table::split_row_keeping_cells(laid, room).is_some()
|
||||
split_keeping_nested(doc, laid, room).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
@@ -187,8 +242,7 @@ 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 && table::split_row_keeping_cells(row, room).is_some()))
|
||||
{
|
||||
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) {
|
||||
pb.advance(block, body_top);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +277,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(next, laid, pb.bottom - pb.y - height, page_height)
|
||||
height <= page_height + 0.01 && !row_can_start(ctx.doc, 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 {
|
||||
@@ -238,8 +292,9 @@ 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) => table::split_row_keeping_cells(cur, room),
|
||||
(true, false) => table::split_row(cur, room),
|
||||
(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)),
|
||||
};
|
||||
match split {
|
||||
Some((a, b)) => {
|
||||
@@ -294,7 +349,13 @@ 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, legacy, 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,
|
||||
ctx.doc.settings.compat_mode >= 15,
|
||||
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.
|
||||
|
||||
+293
-4
@@ -9,10 +9,12 @@ 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;
|
||||
@@ -5593,7 +5595,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);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -5620,22 +5622,67 @@ 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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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()
|
||||
@@ -6968,6 +7015,48 @@ 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.
|
||||
@@ -8891,6 +8980,34 @@ 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;
|
||||
@@ -10238,6 +10355,20 @@ 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};
|
||||
@@ -10271,6 +10402,79 @@ 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":
|
||||
@@ -13491,6 +13695,91 @@ 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(¶.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();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
@@ -208,6 +208,7 @@ 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();
|
||||
|
||||
+44
-21
@@ -166,6 +166,15 @@ 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| {
|
||||
@@ -177,6 +186,40 @@ 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))
|
||||
@@ -221,27 +264,7 @@ impl Exporter<'_> {
|
||||
Placed::Lines { story, path, para, .. } => {
|
||||
let idx = self.entry(*story, path, para);
|
||||
for d in &draws {
|
||||
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)),
|
||||
}
|
||||
self.line_item(s, d, idx);
|
||||
}
|
||||
}
|
||||
Placed::Image { story, path, off, .. } => {
|
||||
|
||||
@@ -8,7 +8,10 @@ pub(crate) fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
|
||||
if !title.trim().is_empty() {
|
||||
m.title = Some(title);
|
||||
}
|
||||
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
|
||||
// "Remove personal information from file properties on save" covers the PDF's author too:
|
||||
// the document's creator is left out (an author the caller names is still written).
|
||||
let kept = if doc.settings.remove_personal_info { String::new() } else { c.creator.clone() };
|
||||
let author = opts.author.clone().unwrap_or(kept);
|
||||
if !author.trim().is_empty() {
|
||||
m.authors = vec![author];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
|
||||
if alt.trim().is_empty() { kind.noun() } else { alt }
|
||||
}
|
||||
|
||||
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
|
||||
/// 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).
|
||||
pub(crate) fn holds_picture(d: &Draw) -> bool {
|
||||
match d {
|
||||
Draw::Image { .. } => true,
|
||||
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
|
||||
Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,6 +1190,8 @@ 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 }),
|
||||
];
|
||||
@@ -1202,6 +1204,43 @@ 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]
|
||||
@@ -2862,3 +2901,14 @@ fn baseline_is_the_most_shared_y() {
|
||||
assert_eq!(baseline(&[(1, 0.0, 9.0), (2, 3.0, 4.0)]), Some(4.0));
|
||||
assert_eq!(baseline(&[(1, 0.0, -0.0), (2, 3.0, 0.0)]), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_author_is_left_out_when_personal_information_is_removed() {
|
||||
let mut d = Document::from_text("Private");
|
||||
d.core.creator = "Ada Writer".into();
|
||||
let opts = PdfOptions { tagged: false, compress: false, ..Default::default() };
|
||||
let raw = |d: &Document| String::from_utf8_lossy(&export(d, &opts).unwrap()).to_string();
|
||||
assert!(raw(&d).contains("Ada Writer"));
|
||||
d.settings.remove_personal_info = true;
|
||||
assert!(!raw(&d).contains("Ada Writer"), "the creator stays out of the PDF");
|
||||
}
|
||||
|
||||
@@ -184,23 +184,28 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |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));
|
||||
// The Print page fills the pane and scrolls its settings and its preview itself (#1156).
|
||||
if app.ui.backstage_page == "print" {
|
||||
crate::rtl::root(ui, |ui| export_page(app, ui));
|
||||
pad.show(ui, |ui| crate::rtl::root(ui, |ui| export_page(app, ui)));
|
||||
return;
|
||||
}
|
||||
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),
|
||||
// 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),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,3 +1054,55 @@ 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:?}");
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
//! 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(§).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");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ 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(¶.props));
|
||||
// Graduations in the File › Options unit.
|
||||
let (unit, div, step) = ruler_scale(app.session.units());
|
||||
@@ -101,6 +106,11 @@ 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));
|
||||
|
||||
@@ -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.
|
||||
const STYLE_SET_W: f32 = 56.0;
|
||||
pub 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;
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ 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). Returns the box and the space the thumbnails used.
|
||||
/// 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.
|
||||
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(egui::Layout::left_to_right(egui::Align::Min)));
|
||||
let mut gui = ui.new_child(egui::UiBuilder::new().max_rect(gallery).layout(crate::rtl::row_layout(ui, 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);
|
||||
|
||||
@@ -113,6 +113,49 @@ 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.
|
||||
|
||||
@@ -5837,6 +5837,51 @@ 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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+17
-2
File diff suppressed because one or more lines are too long
+21
-6
File diff suppressed because one or more lines are too long
@@ -8,6 +8,12 @@ WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
tests together. Line breaking and pagination are in [`layout-parity.md`](layout-parity.md); the
|
||||
overall picture is in [`target-app-parity.md`](target-app-parity.md).
|
||||
|
||||
## Finding smart-quoted text (#5388)
|
||||
|
||||
Ordinary Find and Replace treat a keyboard apostrophe (`'`) as a straight or typographic single quote (`‘`, `’`), so a search for `Tau'va` finds `Tau’va` typed with smart quotes. Partial queries, case options and whole-word matching still apply. Wildcards, regular expressions, literal searches (`special: false`) and numeric character codes such as `^u39` remain exact. A curly-quote query also stays exact, and the modifier-letter apostrophe `ʼ` is distinct. Replacement `^&` keeps the original matched spelling. This behavior is covered by original engine and CLI/MCP controls; no fresh Word or native desktop comparison is claimed.
|
||||
|
||||
|
||||
|
||||
## Highlight shortcut (#4085)
|
||||
|
||||
On Windows/Linux, Ctrl+Alt+H toggles the last chosen highlighter colour (yellow in a
|
||||
@@ -108,6 +114,7 @@ formatting of the text before the cursor. Not yet compared side by side in Word.
|
||||
| Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` |
|
||||
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
|
||||
| Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` |
|
||||
| Accept All / Reject All of a paragraph left empty, whose mark carries a section break (tracking on, or a Compare result) | the paragraph goes whole and its section joins the following one, as when the break is deleted; the next paragraph keeps its own style and numbering, as for an empty paragraph without a break (#5667) | `resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties` |
|
||||
| Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` |
|
||||
| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table (each cell as below). A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` |
|
||||
| Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` |
|
||||
@@ -577,13 +584,25 @@ command is one Undo step and still obeys editing protection and the 1–1638 pt
|
||||
limits. Original-fixture engine and Linux CLI/headless UI save/reopen checks cover
|
||||
this correction; native Windows interaction and Microsoft Word remain untested.
|
||||
|
||||
## Empty paragraph removal
|
||||
|
||||
Deleting a paragraph mark whose preceding paragraph was already empty preserves the
|
||||
following paragraph's paragraph properties and mark formatting. Existing inline runs
|
||||
remain intact. Delete, Backspace and selected marks share the model operation; joining
|
||||
two originally non-empty paragraphs retains the first paragraph's properties. Synthetic
|
||||
regressions cover body, header and cell containers, sections, outline/TOC membership,
|
||||
DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked |
|
||||
| 2026-11-07 | trivial | Removing an already-empty paragraph preserves the following paragraph properties, mark and inline formatting. Delete, Backspace, selected marks, outline/TOC, DOCX and Undo/Redo have synthetic coverage (Leaf #152); no new Word or native acceptance run. |
|
||||
| 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not |
|
||||
| 2026-11-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) |
|
||||
| 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) |
|
||||
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
|
||||
| 2026-11-01 | minor | English spelling by dialect from SCOWL 2026: typed British, Canadian and Australian text, modern words and ordinals pinned (#40, #520, #1281, #1045; CP2) |
|
||||
| 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged |
|
||||
| 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). |
|
||||
|
||||
+3
-1
@@ -42,9 +42,10 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); Return on the Print page prints, as in Word, whose ⌘P opens on Print as the default button (not while a field or list on the page has the keyboard); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129; File › Options works there, AutoCorrect Options included, #4585), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 |
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text. Inside a Drawing Canvas a click picks the member under it (shape, picture or text box) on its own, its frame and handles shown inside the canvas frame; it is dragged, resized, turned and nudged inside the canvas, and Shape Format, Delete, Cut and Copy act on it alone; a click in a text box member's text puts the caret there (#2637, `objects::tests::clicking_and_dragging_inside_a_canvas_works_on_its_members`). Not yet: Shift+click on several members, dragging a member out of its canvas, drag-selecting a text box member's text, members of a turned canvas | ✅ | 75% | — |
|
||||
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878); in a section of several text columns the spaces between them are shaded like the margins (Print Layout, in the main text outside tables, with the selection in one section), an edge dragged changes the spacing (equal columns stay equal, as in Word for Mac), one undo step per drag (#5197) | ✅ | 92% | 1 |
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — |
|
||||
| On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — |
|
||||
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332), and the arrow over each handle turns with the frame to the nearest of the four resize arrows (#3932); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 |
|
||||
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — |
|
||||
| On-canvas objects: select, drag, Ctrl+drag to copy, Ctrl+Shift+drag to copy along an axis, resize handles, nudge | ✅ tested (#228, #1014); Ctrl/Command+Shift on a selected floating picture, shape or text box copies along the dominant horizontal or vertical axis, with guides only along the free axis; the original and clipboard stay, the duplicate is selected, and the drop is one undo step. Inline copies still follow the text insertion cursor (#4764). Synthetic Linux pointer/render tests only; native desktop and Word untested; an In Line with Text object drags within the text as in Word: a bold insertion cursor (solid, twice the caret's width) follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234, #4195) | ✅ | 75% | — |
|
||||
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), pictures and shapes without text stories preview their content turning while the pointer is held; release commits one `arrange.rotation` command and Escape cancels (#2277); text boxes retain a frame-only rotation preview, with text staying upright; turned frames resize along their axes (#332); crop handles: Picture Format › Crop puts the picture in crop mode, black crop handles trim a side (a corner, two) keeping the scale and the visible part in place, the cropped-away part shows dimmed, Esc/Enter/a click outside/Crop again keep the crop, one undo step per drag (unturned pictures; no Crop to Shape, Aspect Ratio or Fill/Fit); alignment guides, our own design: a moved picture, shape or text box snaps its left, centre or right (top, middle or bottom) to the page's edges and centre, the margins and the other floating objects on the page within 6 screen points, x and y independently, turned objects by their rotated bounds, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing (`ui.alignmentGuides`) turns them off. Smart guides missing | Word for Mac 16.113.4 shows no guide lines and snaps inconsistently (to the page's left edge, not its top, right or bottom; Word check K); Word for Windows' guides not observed | 75% | 1–2 |
|
||||
| Tables on the page: drag column and row borders, resize handle past the bottom-right corner; select columns, rows and cells; "+" insert buttons; move handle | ✅ resize cursors, dashed guide, one undo step per drag, selection kept; Shift moves one column; Esc cancels (#217); Ctrl shares the change among the columns on the right, the left edge moves a left-aligned table's indent (a centred or right-aligned one keeps its alignment), a border of cells selected across moves only in their rows, Alt shows the column widths on the ruler (#711); double-click a column border to AutoFit it (a nested table's within the cell around it, #971); selection arrows above a column, left of a row and along a cell's left edge (a drag selects more; the cells are shaded and a column is edited cell by cell); "+" left of a row border and above a column border; move handle past the top-left corner (a click selects the table, a drag floats it where it's dropped); both handles also show while the caret is in the table, as in Word (#2428); an ordinary drag, Shift+arrows or Shift+click from one cell into another select the rectangle of whole cells between them, and into or out of a table whole rows, their cells shaded whole (#971). Missing: a single selected cell's border; The Cell Size Height and Width boxes set every selected row and column (#1242). | ✅ | 95% | 1 |
|
||||
@@ -118,6 +119,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
|
||||
| 2026-10-17 | trivial | Drawing Canvas members are selected with a click and dragged, resized, turned, nudged, formatted, deleted and copied on their own inside the canvas; a click in a text box member's text puts the caret there (#2637) |
|
||||
| 2026-11-05 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a bold, solid insertion cursor, twice the caret's width, as in Word; the dashed one was hard to see (#4195) |
|
||||
| 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. |
|
||||
| 2026-11-01 | trivial | Rulers: the spaces between text columns are shaded on the horizontal ruler (Print Layout, in the main text outside tables, with the selection in one section); dragging an edge changes the spacing; equal columns stay equal, as in Word for Mac (#5197) |
|
||||
| 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts |
|
||||
| 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland |
|
||||
| 2026-11-04 | trivial | Table Sort rejects malformed or nonexistent explicit columns before mutation; valid columns, the omitted-column default and history are retained (#5531). Readiness estimates unchanged. |
|
||||
|
||||
@@ -159,6 +159,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `wordcraft:ready`, `wordcraft:open`, `wordcraft:save`, `wordcraft:dirty` and `wordcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
### Driving it from the page (`window.wordcraft`)
|
||||
|
||||
Every WordCraft web build puts `window.wordcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `wordcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects. WordCraft's control requests cannot be withdrawn, so a call that times out in the page still runs when the app reaches it, as it does for a desktop client.
|
||||
|
||||
```js
|
||||
const doc = await wordcraft.docInspect(); // a summary of the open document
|
||||
await wordcraft.execute("text.insert", { text: "Hello" });
|
||||
const { data } = await wordcraft.screenshot(); // the canvas as PNG (base64)
|
||||
```
|
||||
|
||||
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
Reference in New Issue
Block a user