mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-08 11:19:54 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b3adb858 | ||
|
|
6ba25ee491 |
Generated
+1
-13
@@ -1158,21 +1158,10 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -7109,7 +7098,6 @@ dependencies = [
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
|
||||
+1
-61
File diff suppressed because one or more lines are too long
@@ -17,9 +17,7 @@ path = "src/main.rs"
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
|
||||
//! when the app reaches it, as it does for a desktop client that timed out.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "wordcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
|
||||
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
|
||||
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
if queue.send(control).is_err() {
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(wait(reply))
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
/// The app's reply, polled about once a frame.
|
||||
async fn wait(reply: Receiver<Value>) -> Value {
|
||||
loop {
|
||||
match reply.try_recv() {
|
||||
Ok(reply) => return reply,
|
||||
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
|
||||
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
@@ -20,8 +20,6 @@ mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -76,8 +76,7 @@ pub fn start() {
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let control = crate::api::install(cc.egui_ctx.clone());
|
||||
let mut app = WordApp::new(session, services).with_control(control);
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
|
||||
@@ -69,8 +69,6 @@ impl eframe::App for App {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
let b = self.0.window_builder().clone().with_inner_size(size);
|
||||
self.0.set_window_builder(b);
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.SenuraMalaka]
|
||||
real_name = "Senura Dissanayake"
|
||||
sync_github_name = true
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
@@ -201,6 +197,3 @@ display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -772,10 +772,6 @@ impl Document {
|
||||
if a.path.parent() == b.path.parent() {
|
||||
// Same container: trim the ends, drop the blocks between, join.
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Removing an already-empty paragraph must not impose its style or mark formatting
|
||||
// on the following content. Test before trimming: deleting actual heading text is
|
||||
// still an ordinary merge that keeps the first paragraph's properties.
|
||||
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
@@ -823,18 +819,15 @@ impl Document {
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
|
||||
let keep_mark = empty_start.then(|| tail.mark.clone());
|
||||
let keep_props = pa.props.clone();
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
if let Some(mark) = keep_mark {
|
||||
pa.mark = mark;
|
||||
}
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
|
||||
@@ -19,12 +19,6 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
// Exporters drop other control characters and noncharacters when they write the link
|
||||
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
|
||||
// stands and written as something else.
|
||||
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
|
||||
return false;
|
||||
}
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
@@ -206,10 +200,6 @@ mod tests {
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
// Characters the exporters drop on writing don't hide a scheme.
|
||||
"file\u{85}:///etc/passwd",
|
||||
"java\u{9F}script:alert(1)",
|
||||
"/\u{FFFE}/attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -186,11 +186,6 @@ pub struct Float {
|
||||
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
|
||||
#[serde(skip_serializing_if = "is_zero_u32")]
|
||||
pub z: u32,
|
||||
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
|
||||
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
|
||||
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub bare_turn: bool,
|
||||
}
|
||||
|
||||
/// Longest alternative text kept (characters): hostile files and commands are capped.
|
||||
@@ -246,7 +241,6 @@ impl Float {
|
||||
self.rot = wordcraft_geom::normalize_degrees(s.deg);
|
||||
self.flip_h = s.flip_h;
|
||||
self.flip_v = s.flip_v;
|
||||
self.bare_turn = false;
|
||||
}
|
||||
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
|
||||
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
|
||||
@@ -256,23 +250,6 @@ impl Float {
|
||||
let (bw, bh) = self.spin().extent(w, h);
|
||||
((bw - w) / 2.0, (bh - h) / 2.0)
|
||||
}
|
||||
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
|
||||
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
|
||||
pub fn is_bare_turn(&self) -> bool {
|
||||
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
|
||||
}
|
||||
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
|
||||
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
|
||||
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
|
||||
/// and from 225° up to 315°.
|
||||
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
|
||||
if !self.is_bare_turn() {
|
||||
return self.spin_pad(w, h);
|
||||
}
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
|
||||
let deg = wordcraft_geom::normalize_degrees(self.rot);
|
||||
if modern && ((45.0..135.0).contains(°) || (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,8 +27,7 @@ pub use char_props::{
|
||||
};
|
||||
pub use color::{Rgb, TextColor};
|
||||
pub use para_props::{
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
|
||||
TextAlignment, TextFrame,
|
||||
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
|
||||
};
|
||||
pub use table_props::{
|
||||
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
|
||||
|
||||
@@ -26,67 +26,6 @@ impl Align {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
|
||||
///
|
||||
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
|
||||
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParagraphTextDirection {
|
||||
LrTb,
|
||||
LrTbV,
|
||||
Tb,
|
||||
TbRl,
|
||||
TbRlV,
|
||||
TbLr,
|
||||
TbLrV,
|
||||
BtLr,
|
||||
TbV,
|
||||
Rl,
|
||||
RlV,
|
||||
Lr,
|
||||
LrV,
|
||||
}
|
||||
|
||||
impl ParagraphTextDirection {
|
||||
pub fn from_ooxml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"lrTb" => Some(Self::LrTb),
|
||||
"lrTbV" => Some(Self::LrTbV),
|
||||
"tb" => Some(Self::Tb),
|
||||
"tbRl" => Some(Self::TbRl),
|
||||
"tbRlV" => Some(Self::TbRlV),
|
||||
"tbLr" => Some(Self::TbLr),
|
||||
"tbLrV" => Some(Self::TbLrV),
|
||||
"btLr" => Some(Self::BtLr),
|
||||
"tbV" => Some(Self::TbV),
|
||||
"rl" => Some(Self::Rl),
|
||||
"rlV" => Some(Self::RlV),
|
||||
"lr" => Some(Self::Lr),
|
||||
"lrV" => Some(Self::LrV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::LrTb => "lrTb",
|
||||
Self::LrTbV => "lrTbV",
|
||||
Self::Tb => "tb",
|
||||
Self::TbRl => "tbRl",
|
||||
Self::TbRlV => "tbRlV",
|
||||
Self::TbLr => "tbLr",
|
||||
Self::TbLrV => "tbLrV",
|
||||
Self::BtLr => "btLr",
|
||||
Self::TbV => "tbV",
|
||||
Self::Rl => "rl",
|
||||
Self::RlV => "rlV",
|
||||
Self::Lr => "lr",
|
||||
Self::LrV => "lrV",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
|
||||
/// ECMA-376 §17.3.1.39).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -452,9 +391,6 @@ pub struct ParaProps {
|
||||
pub suppress_line_numbers: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bidi: Option<bool>,
|
||||
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_direction: Option<ParagraphTextDirection>,
|
||||
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drop_cap: Option<u8>,
|
||||
@@ -526,7 +462,6 @@ impl ParaProps {
|
||||
}
|
||||
|
||||
fn overlay_script_layout(&mut self, patch: &ParaProps) {
|
||||
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
|
||||
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
|
||||
|
||||
@@ -137,12 +137,9 @@ impl ResolvedChar {
|
||||
pub fn uses_complex(&self, c: char) -> bool {
|
||||
self.rtl || self.cs || crate::bidi::is_complex_script(c)
|
||||
}
|
||||
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
|
||||
/// 0.65 of the run's size rounded to the half point, a font size the format can store
|
||||
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
|
||||
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
|
||||
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
|
||||
pub fn draw_size(&self) -> f32 {
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
|
||||
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
|
||||
}
|
||||
/// Baseline shift, points (positive = up).
|
||||
pub fn baseline_shift(&self) -> f32 {
|
||||
@@ -456,32 +453,6 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
|
||||
/// its PDF export): 0.65 of the size, rounded to the half point.
|
||||
#[test]
|
||||
fn raised_and_lowered_text_is_sized_to_the_half_point() {
|
||||
for (size, want) in [
|
||||
(8.0, 5.0),
|
||||
(9.0, 6.0),
|
||||
(10.0, 6.5),
|
||||
(11.0, 7.0),
|
||||
(12.0, 8.0),
|
||||
(13.0, 8.5),
|
||||
(14.0, 9.0),
|
||||
(16.0, 10.5),
|
||||
(20.0, 13.0),
|
||||
(24.0, 15.5),
|
||||
(36.0, 23.5),
|
||||
] {
|
||||
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
|
||||
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
|
||||
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
|
||||
}
|
||||
}
|
||||
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
|
||||
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
|
||||
}
|
||||
|
||||
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
|
||||
/// style never gives one to its text.
|
||||
#[test]
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
//!
|
||||
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
|
||||
|
||||
/// The symbol table for a single family name, by lowercase family name.
|
||||
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
let f = family.trim();
|
||||
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
|
||||
Some(&SYMBOL)
|
||||
@@ -37,11 +37,6 @@ fn single_table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol fonts this module knows, by lowercase family name.
|
||||
fn table(family: &str) -> Option<&'static [u32; 224]> {
|
||||
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
|
||||
}
|
||||
|
||||
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
|
||||
/// Webdings), whose characters [`symbol_char`] maps.
|
||||
pub fn is_symbol_font(family: &str) -> bool {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
|
||||
fn fixture(first: &str, style: Option<&str>) -> Document {
|
||||
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Heading1".into());
|
||||
p.props.outline_level = Some(0);
|
||||
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
p.props = ParaProps {
|
||||
style: style.map(str::to_string),
|
||||
align: Some(Align::Right),
|
||||
indent_left: Some(18.0),
|
||||
space_after: Some(7.0),
|
||||
keep_next: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
|
||||
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Compare paragraph, mark and inline-run formatting independently of text.
|
||||
fn assert_format(p: &Paragraph, expected: &Paragraph) {
|
||||
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
|
||||
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
|
||||
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
|
||||
}
|
||||
|
||||
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
|
||||
#[test]
|
||||
fn deleting_an_empty_paragraph_keeps_the_following_format() {
|
||||
for first_style in ["Heading1", "Heading3", "Normal"] {
|
||||
for style in [None, Some("Normal"), Some("Heading2")] {
|
||||
let mut d = fixture("", style);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, expected.text);
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
|
||||
#[test]
|
||||
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
|
||||
for backwards in [false, true] {
|
||||
let mut d = fixture("", None);
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
|
||||
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
|
||||
expected.delete(0, 5).unwrap();
|
||||
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
|
||||
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
|
||||
assert_eq!(d.body.len(), 1);
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "paragraph.");
|
||||
assert_format(p, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A control whose closing marker survives must move across the join without replacing tail formatting.
|
||||
#[test]
|
||||
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
|
||||
use wordcraft_doc::control::ContentControl;
|
||||
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
|
||||
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
|
||||
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
|
||||
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
|
||||
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
|
||||
p.mark.ins = Some(0);
|
||||
p.mark.del = Some(1);
|
||||
let expected = p.clone();
|
||||
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
|
||||
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
|
||||
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
|
||||
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
|
||||
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
|
||||
let p = d.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props, expected.props);
|
||||
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
|
||||
assert_eq!(p.props_of_char(0), &marker);
|
||||
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
|
||||
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
|
||||
let ranges = d.control_ranges(StoryRef::Body);
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, Pos::body(0, 0));
|
||||
assert_eq!(ranges[0].end, Pos::body(1, 5));
|
||||
assert!(!d.has_unbalanced_controls());
|
||||
}
|
||||
|
||||
/// The empty-paragraph rule applies in header stories and table-cell containers.
|
||||
#[test]
|
||||
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
|
||||
for cell in [false, true] {
|
||||
let mut d = fixture("", Some("Normal"));
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let (story, a, b) = if cell {
|
||||
let mut table = Table::new(1, 1, 300.0);
|
||||
table.rows[0].cells[0].blocks = d.body.clone();
|
||||
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
|
||||
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
|
||||
} else {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
|
||||
(StoryRef::Part(id), Path::top(0), Path::top(1))
|
||||
};
|
||||
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
|
||||
assert_format(d.para(story, &a).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorbing an empty section keeps the following format and inherited header.
|
||||
#[test]
|
||||
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
|
||||
let mut d = fixture("", None);
|
||||
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
|
||||
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
|
||||
let mut section = d.last_section.clone();
|
||||
section.headers.default = Some(header);
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
|
||||
d.last_section.margin_left = 96.0;
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
|
||||
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
|
||||
assert_eq!(d.last_section.headers.default, Some(header));
|
||||
assert_eq!(d.last_section.margin_left, 96.0);
|
||||
}
|
||||
|
||||
/// Deleting actual text continues to use the first paragraph merge rule.
|
||||
#[test]
|
||||
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
|
||||
for off in [0, 7] {
|
||||
let mut d = fixture("Heading", None);
|
||||
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
|
||||
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removing a soft line break must not change paragraph formatting.
|
||||
#[test]
|
||||
fn a_soft_break_is_not_an_empty_paragraph_merge() {
|
||||
let mut d = fixture("", None);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
|
||||
let expected = p.props.clone();
|
||||
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
|
||||
assert_eq!(d.body.len(), 2);
|
||||
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
|
||||
}
|
||||
@@ -211,11 +211,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
|
||||
kind = ShapeKind::TextBox;
|
||||
}
|
||||
let line = kind == ShapeKind::Line;
|
||||
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
|
||||
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
|
||||
// drawn as their box here, which a default fill would make an opaque block over the text.
|
||||
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
|
||||
let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
|
||||
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
|
||||
// Line widths are EMUs; Word's default is 0.75 pt.
|
||||
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
|
||||
|
||||
@@ -260,9 +260,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
};
|
||||
|
||||
let mut doc = Document::new();
|
||||
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
|
||||
// 15 would apply Word 2013+ justification and line-breaking rules to them.
|
||||
doc.settings.compat_mode = 11;
|
||||
if let Some(core) = metadata::read(&mut comp) {
|
||||
doc.core = core;
|
||||
}
|
||||
|
||||
@@ -337,15 +337,6 @@ fn empty_document_gets_a_paragraph() {
|
||||
assert_eq!(doc.body.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
|
||||
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
|
||||
let doc = read(&f).expect("opens");
|
||||
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
|
||||
// line breaking.
|
||||
assert_eq!(doc.settings.compat_mode, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_text_stays_out_of_body() {
|
||||
let f = text_doc(
|
||||
|
||||
@@ -183,41 +183,6 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
|
||||
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
|
||||
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
|
||||
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
|
||||
// white fill.
|
||||
let brace = sp(&[fsp(88, 1026, 0xA00)]);
|
||||
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
|
||||
let rect = sp(&[fsp(1, 1028, 0xA00)]);
|
||||
let art = art_content(&[], &[brace, bracket, rect]);
|
||||
let flags = spa_flags(0, 0, 3, false);
|
||||
let spas =
|
||||
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let fills: Vec<_> = p0
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fills,
|
||||
[
|
||||
(None, Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
|
||||
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
|
||||
|
||||
@@ -350,7 +350,6 @@ impl Package {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -406,13 +405,11 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub list: Vec<Rel>,
|
||||
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
|
||||
ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn by_id(&self, id: &str) -> Option<&Rel> {
|
||||
self.ids.get(id).and_then(|&i| self.list.get(i))
|
||||
self.list.iter().find(|r| r.id == id)
|
||||
}
|
||||
pub fn by_type(&self, full: &str) -> Option<&Rel> {
|
||||
self.list.iter().find(|r| rel_is(&r.kind, full))
|
||||
@@ -550,22 +547,6 @@ mod tests {
|
||||
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_are_looked_up_by_id_the_first_one_winning() {
|
||||
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..20_000 {
|
||||
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
|
||||
}
|
||||
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
|
||||
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
|
||||
let r = Package::open(&z).unwrap().rels("word/document.xml");
|
||||
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
|
||||
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
|
||||
assert!(r.by_id("rId20000").is_none());
|
||||
// Hyperlink-style lookups, one per relationship, are each a map lookup.
|
||||
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
|
||||
@@ -66,9 +66,6 @@ pub(super) struct Axis {
|
||||
|
||||
pub(super) struct Series {
|
||||
pub(super) name: String,
|
||||
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
|
||||
/// when missing or not a valid number.
|
||||
pub(super) idx: Option<usize>,
|
||||
pub(super) color: Option<Rgb>,
|
||||
pub(super) line_w: f32,
|
||||
/// Whether the series is drawn as a line (scatter series without one get markers).
|
||||
|
||||
@@ -91,11 +91,8 @@ impl Chart {
|
||||
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
||||
}
|
||||
|
||||
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
|
||||
/// doesn't matter), else the one its place in the file picks.
|
||||
pub(super) fn series_colour(&self, j: usize) -> Rgb {
|
||||
let s = self.series.get(j);
|
||||
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
|
||||
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
||||
}
|
||||
|
||||
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
||||
@@ -202,7 +199,6 @@ pub(super) fn text_size(space: &El) -> f32 {
|
||||
impl Series {
|
||||
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
||||
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
||||
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
|
||||
let sp = ser.child("c:spPr");
|
||||
let ln = sp.and_then(|p| p.child("a:ln"));
|
||||
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
||||
@@ -223,7 +219,7 @@ impl Series {
|
||||
}
|
||||
}
|
||||
let labels = show_values(ser.child("c:dLbls"));
|
||||
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,65 +528,3 @@ fn negative_sections_and_scientific_numbers() {
|
||||
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
||||
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
||||
}
|
||||
|
||||
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
|
||||
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
|
||||
// `c:order` changes nothing.
|
||||
|
||||
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
|
||||
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
|
||||
let plot = format!(
|
||||
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
||||
sers.concat()
|
||||
);
|
||||
let items = with_legend(&plot, "r", 300.0, 200.0);
|
||||
let bars = filled(&items).iter().map(|b| b.0).collect();
|
||||
let keys = items
|
||||
.iter()
|
||||
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
|
||||
.collect();
|
||||
(bars, keys)
|
||||
}
|
||||
|
||||
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
|
||||
fn renumbered(ser: &str, idx_order: &str) -> String {
|
||||
let start = ser.find("<c:idx").expect("ser writes c:idx");
|
||||
let end = ser.find("<c:tx>").expect("ser writes c:tx");
|
||||
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_series_colours_follow_c_idx() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
|
||||
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
|
||||
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
|
||||
// A lone series with idx 2 is accent3.
|
||||
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&lone).0, [accent(2)]);
|
||||
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
|
||||
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
|
||||
// A series' own colour still wins.
|
||||
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
|
||||
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_without_a_usable_c_idx_take_their_place() {
|
||||
let accent = |k: usize| theme()[4 + k];
|
||||
let a = ser(0, "A", None, &["Q1"], &["1"]);
|
||||
let b = ser(1, "B", None, &["Q1"], &["2"]);
|
||||
// No c:idx at all: the place in the file.
|
||||
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
|
||||
// Junk, negative and out-of-range values: no panic, the place in the file.
|
||||
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
|
||||
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
|
||||
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
|
||||
}
|
||||
// The largest unsignedInt `c:idx` allows draws, without a panic.
|
||||
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
|
||||
assert_eq!(column_colours(&big).0.len(), 1);
|
||||
}
|
||||
|
||||
@@ -239,13 +239,7 @@ impl Reader<'_> {
|
||||
/// Keep one part's bytes and content type (not its relationships).
|
||||
fn keep_part(&mut self, part: &str) -> bool {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
|
||||
if !manifest_field_ok(part)
|
||||
|| lower.ends_with(".rels")
|
||||
|| lower == "[content_types].xml"
|
||||
|| lower.starts_with("wordcraft:")
|
||||
|| part.eq_ignore_ascii_case(&self.main)
|
||||
{
|
||||
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
|
||||
log::warn!("docx: not carrying part {part:?} for an embedded object");
|
||||
return false;
|
||||
}
|
||||
@@ -394,23 +388,6 @@ mod tests {
|
||||
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
|
||||
}
|
||||
|
||||
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
|
||||
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
|
||||
#[test]
|
||||
fn a_part_named_like_bookkeeping_is_not_carried() {
|
||||
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
|
||||
let bytes = package(
|
||||
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
|
||||
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
|
||||
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
|
||||
);
|
||||
let doc = crate::read(&bytes).unwrap();
|
||||
let roots = unmodelled_roots(&doc);
|
||||
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
|
||||
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
|
||||
assert!(!manifest.contains("evil"), "{manifest}");
|
||||
}
|
||||
|
||||
/// An unqualified attribute is a relationship only where the markup names a relationship
|
||||
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
|
||||
#[test]
|
||||
|
||||
@@ -55,8 +55,6 @@ impl Reader<'_> {
|
||||
.children("w:font")
|
||||
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
|
||||
.filter_map(|f| f.attr("w:name"))
|
||||
.flat_map(|n| n.split(';'))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
|
||||
.take(MAX_TABLE_FONTS)
|
||||
.map(str::to_string)
|
||||
|
||||
@@ -5,17 +5,16 @@ use std::collections::HashMap;
|
||||
use wordcraft_doc::para::cap_alt;
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
|
||||
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
|
||||
TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
|
||||
VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{
|
||||
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
|
||||
PageVAlign, SectionProps, SectionStart,
|
||||
};
|
||||
|
||||
use super::MAX_FONT_NAME;
|
||||
use super::drawing_color::adjust_lum;
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
|
||||
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
|
||||
|
||||
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
|
||||
/// points (clamped), names kept as OOXML writes them.
|
||||
@@ -146,29 +145,6 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
|
||||
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
|
||||
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
|
||||
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
|
||||
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
|
||||
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
|
||||
/// drop an over-long name.
|
||||
fn bounded_font_list(v: &str) -> Option<String> {
|
||||
if v.len() <= MAX_FONT_NAME {
|
||||
return (!v.is_empty()).then(|| v.to_string());
|
||||
}
|
||||
let mut end = 0usize;
|
||||
for part in v.split_inclusive(';') {
|
||||
let next = end + part.len();
|
||||
if next > MAX_FONT_NAME {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
let kept = v.get(..end)?.trim_end_matches(';');
|
||||
(!kept.is_empty()).then(|| kept.to_string())
|
||||
}
|
||||
|
||||
impl PropCtx {
|
||||
pub fn style_id(&self, id: &str) -> String {
|
||||
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
|
||||
@@ -250,8 +226,6 @@ impl PropCtx {
|
||||
shd_fill(e)
|
||||
}
|
||||
|
||||
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
|
||||
/// weight and posture of the complex-script and East Asian text beside them.
|
||||
pub fn rpr(&self, e: &El) -> CharProps {
|
||||
let mut c = CharProps::default();
|
||||
for k in e.els() {
|
||||
@@ -260,11 +234,7 @@ impl PropCtx {
|
||||
"w:rFonts" => {
|
||||
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
|
||||
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
|
||||
// A LibreOffice file names the Latin font as a semicolon-separated fallback
|
||||
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
|
||||
// corrupt or hostile, and every piece of text drawn in it would pay for its
|
||||
// whole length as the list is classified (see `bounded_font_list`).
|
||||
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
|
||||
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
|
||||
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
|
||||
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
|
||||
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
|
||||
@@ -368,7 +338,6 @@ impl PropCtx {
|
||||
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
|
||||
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
|
||||
"w:bidi" => p.bidi = Some(on_off(k)),
|
||||
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
|
||||
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
|
||||
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
|
||||
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
|
||||
@@ -407,10 +376,10 @@ impl PropCtx {
|
||||
p.tabs = Some(tabs);
|
||||
}
|
||||
"w:spacing" => {
|
||||
if let Some(v) = tw_u16(k, "w:before") {
|
||||
if let Some(v) = tw(k, "w:before") {
|
||||
p.space_before = Some(v.max(0.0));
|
||||
}
|
||||
if let Some(v) = tw_u16(k, "w:after") {
|
||||
if let Some(v) = tw(k, "w:after") {
|
||||
p.space_after = Some(v.max(0.0));
|
||||
}
|
||||
// HTML-style automatic spacing; "0" switches an inherited one off.
|
||||
@@ -433,15 +402,15 @@ impl PropCtx {
|
||||
}
|
||||
}
|
||||
"w:ind" => {
|
||||
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
|
||||
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
|
||||
p.indent_left = Some(v);
|
||||
}
|
||||
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
|
||||
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
|
||||
p.indent_right = Some(v);
|
||||
}
|
||||
if let Some(v) = tw_i16(k, "w:hanging") {
|
||||
if let Some(v) = tw(k, "w:hanging") {
|
||||
p.indent_first = Some(-v);
|
||||
} else if let Some(v) = tw_i16(k, "w:firstLine") {
|
||||
} else if let Some(v) = tw(k, "w:firstLine") {
|
||||
p.indent_first = Some(v);
|
||||
}
|
||||
// Character units (hundredths of a character), which win when non-zero.
|
||||
|
||||
@@ -41,7 +41,6 @@ impl Reader<'_> {
|
||||
}
|
||||
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
|
||||
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
|
||||
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
|
||||
if *e != 0.0 {
|
||||
@@ -107,11 +106,7 @@ impl Reader<'_> {
|
||||
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
|
||||
// Word's effect extent also covers a rotated object's overhang; the model keeps that
|
||||
// apart (it follows from the angle), so take it back out.
|
||||
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
|
||||
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
|
||||
if let Some(f) = obj.float_mut() {
|
||||
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
|
||||
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
|
||||
let (px, py) = f.spin_pad(w, h);
|
||||
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
|
||||
// Zero adds no effects room when rotation contracts this axis.
|
||||
@@ -123,33 +118,9 @@ impl Reader<'_> {
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
|
||||
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
|
||||
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
|
||||
/// part, when the address has one, stays part of the address.
|
||||
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
|
||||
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
|
||||
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
|
||||
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
|
||||
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
|
||||
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
|
||||
/// choice's link is not this object's.
|
||||
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
|
||||
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
|
||||
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
|
||||
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
|
||||
};
|
||||
let link =
|
||||
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
|
||||
Some((object, link))
|
||||
}
|
||||
|
||||
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
|
||||
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
|
||||
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
|
||||
let drawing = choice.child("w:drawing")?;
|
||||
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
|
||||
@@ -178,8 +149,10 @@ impl Reader<'_> {
|
||||
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
|
||||
/// fallback picture, keeping both branches so saving writes the chart back rather than only
|
||||
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
|
||||
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = chartex_choice(ac)?;
|
||||
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
|
||||
let choice = ac
|
||||
.children("mc:Choice")
|
||||
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
|
||||
// A requirement in a namespace we can't name couldn't be written back.
|
||||
let requires = choice.attr("Requires").unwrap_or("");
|
||||
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
|
||||
@@ -590,11 +563,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
|
||||
fn chartex_choice(ac: &El) -> Option<&El> {
|
||||
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
|
||||
}
|
||||
|
||||
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
|
||||
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
|
||||
|
||||
@@ -98,9 +98,9 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"mc:AlternateContent" => {
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
let props = self.run_props_none(ctx);
|
||||
self.emit_linked_obj(sc, pb, o, &props, link);
|
||||
self.emit_obj(sc, pb, o, &props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ impl Reader<'_> {
|
||||
},
|
||||
"w:drawing" => {
|
||||
if let Some(o) = self.read_drawing(sc, k, rels) {
|
||||
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
"w:pict" => {
|
||||
@@ -298,8 +298,8 @@ impl Reader<'_> {
|
||||
),
|
||||
"w:ruby" => self.read_ruby(sc, pb, k, props),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
|
||||
self.emit_linked_obj(sc, pb, o, props, link);
|
||||
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else if let Some(c) = children_of_choice(k) {
|
||||
for x in c.els() {
|
||||
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
|
||||
@@ -376,14 +376,6 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
|
||||
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
|
||||
match link.filter(|_| props.link.is_none()) {
|
||||
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
|
||||
None => self.emit_obj(sc, pb, o, props),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
|
||||
match sink(sc) {
|
||||
Some(f) => {
|
||||
|
||||
@@ -224,15 +224,6 @@ impl Reader<'_> {
|
||||
{
|
||||
let id = self.read_textbox(sc, t, rels);
|
||||
if let Some(part) = self.doc.parts.get_mut(&id) {
|
||||
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
|
||||
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
|
||||
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
|
||||
let tb_style = tb.attr("style").unwrap_or("");
|
||||
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
|
||||
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
|
||||
part.body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
|
||||
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
|
||||
// `o:insetmode="auto"` leaves them to the application.
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::{
|
||||
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
|
||||
VAlign, flag, int, props, tw, u32_of,
|
||||
};
|
||||
use crate::units::tw_i16;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
@@ -318,12 +317,12 @@ impl Reader<'_> {
|
||||
lv.align = props::align(j);
|
||||
}
|
||||
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
|
||||
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
|
||||
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
|
||||
lv.indent = v;
|
||||
}
|
||||
if let Some(h) = tw_i16(ind, "w:hanging") {
|
||||
if let Some(h) = tw(ind, "w:hanging") {
|
||||
lv.hanging = h;
|
||||
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
|
||||
} else if let Some(f) = tw(ind, "w:firstLine") {
|
||||
lv.hanging = -f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,14 +83,13 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
}
|
||||
// An empty paragraph opening the control takes the field. It may end a section, as above
|
||||
// entries in columns (#3889).
|
||||
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
|
||||
if empty_heading {
|
||||
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
|
||||
field_to_heading(&mut engine, s + 1);
|
||||
} else {
|
||||
toc_field_to_heading(&mut engine);
|
||||
}
|
||||
if crate::write::written_toc_span(&engine) == Some((s, last)) {
|
||||
*body = if empty_heading { toc } else { engine };
|
||||
*body = toc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +97,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
|
||||
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
|
||||
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
|
||||
for i in 1..body.len() {
|
||||
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
|
||||
field_to_heading(body, i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,27 +116,6 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
|
||||
e.attr(attr).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
|
||||
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
|
||||
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
|
||||
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) => Some(f32::from(i as i16) / 20.0),
|
||||
Err(_) => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
|
||||
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
|
||||
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
|
||||
let v = e.attr(attr)?;
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
|
||||
_ => measure(v, 20.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
|
||||
pub fn on_off(e: &El) -> bool {
|
||||
match e.attr("w:val") {
|
||||
@@ -169,24 +148,6 @@ pub fn emu(pt: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twips_beyond_16_bits_wrap() {
|
||||
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
|
||||
let s = |a: &str| tw_i16(&el(a), "w:v");
|
||||
let u = |a: &str| tw_u16(&el(a), "w:v");
|
||||
assert_eq!(s("66256"), Some(36.0));
|
||||
assert_eq!(s("-65516"), Some(1.0));
|
||||
assert_eq!(s("98304"), Some(-1638.4));
|
||||
assert_eq!(s("32767"), Some(1638.35));
|
||||
assert_eq!(s("-32768"), Some(-1638.4));
|
||||
assert_eq!(s("1in"), Some(72.0));
|
||||
assert_eq!(s("x"), None);
|
||||
assert_eq!(u("65537"), Some(0.05));
|
||||
assert_eq!(u("65535"), Some(3276.75));
|
||||
assert_eq!(u("12pt"), Some(12.0));
|
||||
assert_eq!(u("-20"), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures() {
|
||||
assert_eq!(measure("1440", 20.0), Some(72.0));
|
||||
|
||||
@@ -499,9 +499,8 @@ impl super::Writer<'_> {
|
||||
}
|
||||
}
|
||||
"wp:effectExtent" => {
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
|
||||
// unless the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(w, h);
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let ext = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
|
||||
|
||||
@@ -287,9 +287,6 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
|
||||
if let Some(a) = p.align {
|
||||
w.val("w:jc", align_val(a));
|
||||
}
|
||||
if let Some(direction) = p.text_direction {
|
||||
w.val("w:textDirection", direction.ooxml());
|
||||
}
|
||||
if let Some(t) = p.text_alignment {
|
||||
w.val("w:textAlignment", t.ooxml());
|
||||
}
|
||||
|
||||
@@ -86,28 +86,11 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
|
||||
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
|
||||
})
|
||||
};
|
||||
// Bookmark, comment and permission markers after the end may stay there: a body-level
|
||||
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
|
||||
// and the control still covers the whole paragraph.
|
||||
let at_end = |b: usize, off: usize| {
|
||||
use wordcraft_doc::InlineObject as O;
|
||||
para(b).is_some_and(|p| {
|
||||
let from = off + OBJ.len_utf8();
|
||||
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
|
||||
matches!(
|
||||
p.object_at(o),
|
||||
Some(
|
||||
O::ControlEnd
|
||||
| O::BookmarkStart { .. }
|
||||
| O::BookmarkEnd { .. }
|
||||
| O::CommentStart { .. }
|
||||
| O::CommentEnd { .. }
|
||||
| O::PermStart { .. }
|
||||
| O::PermEnd { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
|
||||
})
|
||||
};
|
||||
let mut mode = vec![Mode::Inline; ranges.len()];
|
||||
|
||||
@@ -10,19 +10,9 @@ pub(super) struct DrawingInfo<'a> {
|
||||
pub docpr: &'a str,
|
||||
pub name: &'a str,
|
||||
pub alt: &'a str,
|
||||
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
|
||||
pub link: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Writer<'_> {
|
||||
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
|
||||
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
|
||||
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
|
||||
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
|
||||
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
|
||||
Some(rels.add(rt::HYPERLINK, url, true))
|
||||
}
|
||||
|
||||
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
|
||||
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
|
||||
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
|
||||
@@ -30,7 +20,7 @@ impl Writer<'_> {
|
||||
self.used_media.insert(media.clone());
|
||||
w.open("pic:pic", &[]);
|
||||
w.open("pic:nvPicPr", &[]);
|
||||
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
|
||||
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
|
||||
w.open("pic:cNvPicPr", &[]);
|
||||
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
|
||||
w.close("pic:cNvPicPr");
|
||||
@@ -76,7 +66,7 @@ impl Writer<'_> {
|
||||
w.open("wps:wsp", &[]);
|
||||
if let Some(id) = id {
|
||||
let name = shape_name(*kind, freeform.as_deref(), id);
|
||||
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
|
||||
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
|
||||
}
|
||||
if kind.is_connector() {
|
||||
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
|
||||
@@ -245,7 +235,7 @@ impl Writer<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
|
||||
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
|
||||
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
|
||||
if float.wrap == Wrap::Inline {
|
||||
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
|
||||
@@ -298,9 +288,8 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
|
||||
// the file it came from kept none.
|
||||
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
|
||||
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
|
||||
let (px, py) = float.spin_pad(iw, ih);
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
|
||||
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
|
||||
@@ -327,7 +316,7 @@ impl Writer<'_> {
|
||||
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
|
||||
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
|
||||
}
|
||||
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
|
||||
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,9 +502,8 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
|
||||
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
|
||||
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
|
||||
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
|
||||
/// from a file with no description) is not written twice. `link` is the relationship id of the
|
||||
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
|
||||
/// from a file with no description) is not written twice.
|
||||
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
|
||||
let mut attrs = vec![("id", id), ("name", name)];
|
||||
if descr != alt.title || descr.is_empty() {
|
||||
attrs.push(("descr", descr));
|
||||
@@ -526,21 +514,16 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str),
|
||||
if hidden {
|
||||
attrs.push(("hidden", "1"));
|
||||
}
|
||||
if !alt.decorative && link.is_none() {
|
||||
if !alt.decorative {
|
||||
w.empty(tag, &attrs);
|
||||
return;
|
||||
}
|
||||
w.open(tag, &attrs);
|
||||
if let Some(rid) = link {
|
||||
w.empty("a:hlinkClick", &[("r:id", rid)]);
|
||||
}
|
||||
if alt.decorative {
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
}
|
||||
w.open("a:extLst", &[]);
|
||||
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
|
||||
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
|
||||
w.close("a:ext");
|
||||
w.close("a:extLst");
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,18 +220,13 @@ impl Writer<'_> {
|
||||
w.open("mc:Choice", &[("Requires", requires)]);
|
||||
}
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = match graphic.kind {
|
||||
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
|
||||
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
|
||||
};
|
||||
let z = self.z;
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
native.unwrap_or(&root),
|
||||
);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
|
||||
if let Some(fallback) = fallback {
|
||||
w.close("mc:Choice");
|
||||
match picture {
|
||||
@@ -239,11 +234,7 @@ impl Writer<'_> {
|
||||
// Both branches are one object: same id and stacking order.
|
||||
self.z = z;
|
||||
w.open("mc:Fallback", &[]);
|
||||
self.graphic_frame(
|
||||
w,
|
||||
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
|
||||
picture,
|
||||
);
|
||||
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
|
||||
w.close("mc:Fallback");
|
||||
}
|
||||
None => w.raw(&fallback.to_xml()),
|
||||
@@ -275,9 +266,8 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("Picture {docpr}");
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
|
||||
@@ -365,7 +355,6 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.shape_docpr(extra.id);
|
||||
let link = Self::drawing_link(props, rels);
|
||||
// Ink is told apart by its name (and its pen), which reading looks for.
|
||||
let name = shape_name(*kind, freeform.as_deref(), &docpr);
|
||||
// The effect extent leaves room for the shadow and glow.
|
||||
@@ -380,7 +369,7 @@ impl Writer<'_> {
|
||||
.map(std::sync::Arc::new);
|
||||
}
|
||||
let float = &float;
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
|
||||
@@ -400,9 +389,8 @@ impl Writer<'_> {
|
||||
self.rpr(w, props);
|
||||
w.open("w:drawing", &[]);
|
||||
let docpr = self.next_docpr();
|
||||
let link = Self::drawing_link(props, rels);
|
||||
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
|
||||
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
|
||||
w.empty("wp:cNvGraphicFramePr", &[]);
|
||||
w.open("a:graphic", &[]);
|
||||
let outer = match canvas {
|
||||
|
||||
+6
-114
@@ -94,49 +94,6 @@ 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 {
|
||||
@@ -385,8 +342,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_ns = match &res {
|
||||
ResolveResult::Bound(ns) => Some(ns.as_ref()),
|
||||
let el_prefix = match &res {
|
||||
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
|
||||
_ => None,
|
||||
};
|
||||
match ev {
|
||||
@@ -399,8 +356,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_ns {
|
||||
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
|
||||
let name = match el_prefix {
|
||||
Some(p) => format!("{p}:{local}"),
|
||||
None => local,
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
@@ -462,22 +419,15 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
attach(&mut stack, &mut root, el);
|
||||
}
|
||||
}
|
||||
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
|
||||
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
|
||||
// ` ` is a character reference and stays.
|
||||
Event::Text(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if let Some(top) = stack.last_mut() {
|
||||
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
|
||||
let s = t
|
||||
.xml_content(XmlVersion::Explicit1_0)
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
|
||||
let s = String::from_utf8_lossy(&t).into_owned();
|
||||
push_text(top, &s);
|
||||
}
|
||||
}
|
||||
@@ -643,42 +593,6 @@ 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`.
|
||||
@@ -731,28 +645,6 @@ mod tests {
|
||||
assert_eq!(t.attr("xml:space"), Some("preserve"));
|
||||
}
|
||||
|
||||
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
|
||||
#[test]
|
||||
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
|
||||
let x =
|
||||
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
|
||||
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` 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,57 +342,3 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
|
||||
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let content = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2"))
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
|
||||
for pass in 0..2 {
|
||||
let d = read(&bytes).unwrap();
|
||||
bytes = write(&d).unwrap();
|
||||
let xml = document_xml(&bytes);
|
||||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||||
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
|
||||
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
|
||||
// not gain a Table of Contents w:sdt on save.
|
||||
let bare = format!(
|
||||
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
|
||||
style("TOCHeading", &t("Contents")),
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
p(&t("Chapter One")),
|
||||
);
|
||||
let d_bare = read(&package(&bare)).unwrap();
|
||||
let xml_bare = document_xml(&write(&d_bare).unwrap());
|
||||
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
|
||||
|
||||
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
|
||||
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
|
||||
let sect_toc = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
style("TOC2", &t("Section\t2")),
|
||||
);
|
||||
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
|
||||
let d_sect = read(&package(&format!("{}{}", sdt(pr, §_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}");
|
||||
}
|
||||
|
||||
@@ -1766,27 +1766,12 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
|
||||
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
|
||||
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
|
||||
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
|
||||
chartex_docx_linked(requires, requires_ns, false)
|
||||
}
|
||||
|
||||
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
|
||||
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
|
||||
/// takes the chart's choice.
|
||||
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
|
||||
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
|
||||
let other = if link {
|
||||
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let frame = format!(
|
||||
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
|
||||
);
|
||||
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
|
||||
let body = format!(
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
|
||||
);
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
|
||||
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
|
||||
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
|
||||
let types = format!(
|
||||
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
|
||||
@@ -1842,25 +1827,6 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
|
||||
/// and saving keeps it on both branches (the writer rebuilds their frames).
|
||||
#[test]
|
||||
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
|
||||
for _ in 0..2 {
|
||||
let p = d.body[0].as_para().unwrap();
|
||||
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
|
||||
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
|
||||
let files = unzip(&wordcraft_docx::write(&d).unwrap());
|
||||
let body = text(&files, "word/document.xml");
|
||||
let ids = attr_values(&body, "a:hlinkClick", "r:id");
|
||||
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
|
||||
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
|
||||
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
|
||||
d = wordcraft_docx::read(&zip(&entries)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_office_2016_chart_resizes_its_fallback_too() {
|
||||
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
|
||||
@@ -3947,33 +3913,6 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
|
||||
/// within the bound, so the family a real list would pick survives while a hostile list cannot
|
||||
/// cost its whole length in every piece of text drawn in it.
|
||||
#[test]
|
||||
fn a_hostile_fallback_list_keeps_its_leading_families() {
|
||||
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
let list = format!("{};Source Serif 4", candidates.join(";"));
|
||||
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
|
||||
let d = read_body(&body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
let kept = c.font.as_deref().expect("the leading families are kept");
|
||||
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
|
||||
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
|
||||
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
|
||||
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
|
||||
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
|
||||
}
|
||||
|
||||
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
|
||||
#[test]
|
||||
fn a_real_fallback_list_is_read_whole() {
|
||||
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
let d = read_body(body);
|
||||
let c = &d.body[0].as_para().unwrap().runs[0].props;
|
||||
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
}
|
||||
|
||||
/// The font table's family classes: Word draws a missing roman font in a serif face.
|
||||
#[test]
|
||||
fn roman_fonts_are_read_from_the_font_table() {
|
||||
@@ -3983,15 +3922,6 @@ fn roman_fonts_are_read_from_the_font_table() {
|
||||
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
|
||||
}
|
||||
|
||||
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
|
||||
#[test]
|
||||
fn roman_fonts_split_fallback_lists() {
|
||||
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
|
||||
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
|
||||
}
|
||||
|
||||
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
|
||||
#[test]
|
||||
fn html_auto_spacing_off_is_read_and_kept() {
|
||||
@@ -4390,54 +4320,6 @@ fn zero_effects_stay_zero_rotated() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
|
||||
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
|
||||
/// its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turns_are_kept_through_a_save() {
|
||||
let pic = |effects: &str| {
|
||||
format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
|
||||
)
|
||||
};
|
||||
let read =
|
||||
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
|
||||
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
|
||||
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
|
||||
let d = read(&pic(effects));
|
||||
assert!(bare(&d), "{effects:?}");
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
let body = text(&unzip(&out), "word/document.xml");
|
||||
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
}
|
||||
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
|
||||
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
|
||||
assert!(!bare(&d));
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
|
||||
assert!(!f.bare_turn, "turned here: the rotated bounds again");
|
||||
// A shadow's room added later: saved, and so laid out, with the turn's room again.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.effect = [2.0; 4];
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
|
||||
let mut d = read(&pic(""));
|
||||
let f = first_object(&mut d).float_mut().unwrap();
|
||||
f.wrap = wordcraft_doc::para::Wrap::Square;
|
||||
assert!(!f.is_bare_turn());
|
||||
let out = wordcraft_docx::write(&d).unwrap();
|
||||
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
|
||||
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
|
||||
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
|
||||
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
|
||||
assert!(!bare(&read(&anchored)));
|
||||
}
|
||||
|
||||
/// The math namespace, declared on the math element itself (the fixture root doesn't).
|
||||
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
|
||||
|
||||
|
||||
@@ -21,10 +21,8 @@ mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
mod nested_block_controls;
|
||||
mod note_comments;
|
||||
mod passthrough;
|
||||
mod picture_links;
|
||||
mod positional_tabs;
|
||||
mod privacy;
|
||||
mod roundtrip;
|
||||
@@ -36,11 +34,9 @@ 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;
|
||||
mod twips_wrap;
|
||||
mod undefined_style;
|
||||
mod vml_shapes;
|
||||
mod word_xml;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//! A body-level content control nested directly in another body-level one stays body-level when
|
||||
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
|
||||
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{ContentControl, Document, StoryRef};
|
||||
use wordcraft_docx::{read, write};
|
||||
|
||||
const NS: &str =
|
||||
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
|
||||
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(docx: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn t(s: &str) -> String {
|
||||
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
|
||||
}
|
||||
fn p(inner: &str) -> String {
|
||||
format!("<w:p>{inner}</w:p>")
|
||||
}
|
||||
fn sdt(pr: &str, content: &str) -> String {
|
||||
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
|
||||
}
|
||||
|
||||
fn controls(d: &Document) -> Vec<ContentControl> {
|
||||
let mut out = Vec::new();
|
||||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many `w:p` elements are open at byte `at` of `xml`.
|
||||
fn open_paragraphs(xml: &str, at: usize) -> usize {
|
||||
let head = &xml[..at];
|
||||
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
|
||||
opened - head.matches("</w:p>").count()
|
||||
}
|
||||
|
||||
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
|
||||
fn depth_of_control(xml: &str, marker: &str) -> usize {
|
||||
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
|
||||
let start = xml[..at].rfind("<w:sdt>").expect("control start");
|
||||
open_paragraphs(xml, start)
|
||||
}
|
||||
|
||||
fn round_trip(body: &str) -> (Document, Document, String) {
|
||||
let d = read(&package(body)).unwrap();
|
||||
let bytes = write(&d).unwrap();
|
||||
let back = read(&bytes).unwrap();
|
||||
(d, back, document_xml(&bytes))
|
||||
}
|
||||
|
||||
fn repeating_section() -> String {
|
||||
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
|
||||
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
|
||||
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
|
||||
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
|
||||
let (_, _, xml) = round_trip(&repeating_section());
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
// The text control stays inside the paragraph, and the item holds that paragraph.
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
|
||||
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_round_trips_its_controls_and_text() {
|
||||
let (d, back, _) = round_trip(&repeating_section());
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
assert_eq!(back.body.len(), d.body.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
|
||||
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_block_controls_in_a_table_cell_stay_block_level() {
|
||||
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
|
||||
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
|
||||
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
|
||||
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_level_control_stays_inside_its_paragraph() {
|
||||
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
|
||||
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
|
||||
}
|
||||
|
||||
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
|
||||
/// the body-level `_GoBack` bookmark Word leaves after the last control.
|
||||
fn bound_section_body(bound: bool, bookmark: bool) -> String {
|
||||
let section_binding = if bound {
|
||||
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
|
||||
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
|
||||
let field = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
if bound { bind("/a[1]/n[1]") } else { String::new() },
|
||||
t("note 1")
|
||||
);
|
||||
let item = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
|
||||
p(&format!("{field}{}", t(" ")))
|
||||
);
|
||||
let section = format!(
|
||||
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
|
||||
);
|
||||
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
|
||||
}
|
||||
|
||||
fn assert_section_block_level(body: &str) {
|
||||
let (d, back, xml) = round_trip(body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
|
||||
assert_section_block_level(&bound_section_body(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
|
||||
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
|
||||
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
|
||||
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
|
||||
let (d, back, xml) = round_trip(&body);
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
|
||||
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
|
||||
assert_eq!(controls(&back), controls(&d));
|
||||
// The bookmark is kept.
|
||||
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
|
||||
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
|
||||
//! synthetic packages; the 2×2 picture is generated in code.
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// An inline picture's run; `link` goes into its `wp:docPr`.
|
||||
fn picture_run(id: u32, link: &str) -> String {
|
||||
format!(
|
||||
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn package(body: &str) -> Vec<u8> {
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document 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:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let rels = 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 doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
|
||||
let png = png();
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("word/document.xml", xml.as_bytes()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/media/square.png", png.as_slice()),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The link on each picture of the first paragraph, in order.
|
||||
fn picture_links(doc: &Document) -> Vec<Option<String>> {
|
||||
let p = doc.body[0].as_para().unwrap();
|
||||
p.object_offsets()
|
||||
.into_iter()
|
||||
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
|
||||
.map(|off| p.props_of_char(off).link.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_on_a_pictures_drawing_is_read() {
|
||||
let body = format!(
|
||||
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
|
||||
// Word's way: the link on the drawing.
|
||||
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
// A click naming a relationship that is no hyperlink, and one naming none.
|
||||
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
|
||||
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
|
||||
// A link around the run wins over the drawing's.
|
||||
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
|
||||
);
|
||||
let doc = wordcraft_docx::read(&package(&body)).unwrap();
|
||||
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
|
||||
let mut doc = Document::new();
|
||||
doc.media.insert("square.png".into(), Arc::new(png()));
|
||||
let picture = || InlineObject::Image {
|
||||
media: "square.png".into(),
|
||||
w: 20.0,
|
||||
h: 20.0,
|
||||
alt: String::new(),
|
||||
float: Default::default(),
|
||||
crop: [0.0; 4],
|
||||
ole: None,
|
||||
fx: Default::default(),
|
||||
};
|
||||
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
|
||||
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
|
||||
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
let xml = part(&bytes, "word/document.xml");
|
||||
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
|
||||
assert_eq!(clicks.len(), 1, "{xml}");
|
||||
let rid = clicks[0].split('"').nth(1).unwrap();
|
||||
let docpr = xml.find("<wp:docPr ").unwrap();
|
||||
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
|
||||
let rels = part(&bytes, "word/_rels/document.xml.rels");
|
||||
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
|
||||
// The run's own hyperlink names the same relationship, so the address is stored once.
|
||||
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
|
||||
let back = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
|
||||
// The drawing's link alone (as Word writes it) reads back the same.
|
||||
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
|
||||
assert_ne!(without_run_link, xml);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "word/document.xml" {
|
||||
data = without_run_link.clone().into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let edited = out.finish().unwrap().into_inner();
|
||||
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
|
||||
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
|
||||
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
|
||||
};
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
@@ -195,7 +195,6 @@ fn every_para_prop_round_trips() {
|
||||
suppress_hyphens: Some(true),
|
||||
suppress_line_numbers: Some(true),
|
||||
bidi: Some(false),
|
||||
text_direction: Some(ParagraphTextDirection::TbRl),
|
||||
drop_cap: None,
|
||||
drop_cap_margin: None,
|
||||
drop_cap_distance: None,
|
||||
|
||||
@@ -18,25 +18,6 @@ fn docx(body: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
for (name, value) in [
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/_rels/document.xml.rels", document_rels.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
] {
|
||||
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
bytes.write_all(value).unwrap();
|
||||
}
|
||||
bytes.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn document_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
@@ -78,52 +59,3 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
|
||||
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
|
||||
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
use wordcraft_doc::props::ParagraphTextDirection;
|
||||
|
||||
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
|
||||
let mut body: String =
|
||||
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
|
||||
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
|
||||
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
||||
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
|
||||
for (para, value) in paras.iter().zip(values) {
|
||||
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
|
||||
}
|
||||
assert_eq!(paras[values.len()].props.text_direction, None);
|
||||
let json = serde_json::to_value(¶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}");
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! 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,15 +132,6 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
|
||||
// In a group, a member's margins go with that member's text, not an empty box's before it.
|
||||
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
|
||||
// Text direction (#5193), as Word for Windows reads it: `vertical` and
|
||||
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
|
||||
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
|
||||
shape("", r#"style="layout-flow:vertical""#),
|
||||
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
|
||||
shape("", r#"style="layout-flow:vertical-ideographic""#),
|
||||
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
|
||||
shape("", r#"style="layout-flow:horizontal-ideographic""#),
|
||||
]
|
||||
.concat();
|
||||
let doc = format!(
|
||||
@@ -149,15 +140,13 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
|
||||
let rels = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
|
||||
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
|
||||
assert_eq!(bodies.len(), 12);
|
||||
assert_eq!(bodies.len(), 6);
|
||||
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
|
||||
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
|
||||
assert_eq!(bodies[2], TextBody::default());
|
||||
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
|
||||
assert_eq!(bodies[4].inset, None);
|
||||
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
|
||||
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
|
||||
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
|
||||
// Saved and read again, they stay.
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
|
||||
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
|
||||
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
|
||||
//! Fixtures are hand-written.
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
use wordcraft_doc::props::ParaProps;
|
||||
|
||||
use crate::fixtures::zip;
|
||||
|
||||
/// A package whose body is `body`.
|
||||
fn docx(body: &str) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
||||
}
|
||||
|
||||
fn props(body: &str) -> Vec<ParaProps> {
|
||||
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indents_beyond_16_bits_wrap() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!(p[0].indent_left, Some(36.0));
|
||||
assert_eq!(p[1].indent_right, Some(72.0));
|
||||
// 65556 -> 20 twips; -65516 -> 20 twips.
|
||||
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
|
||||
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
|
||||
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_beyond_16_bits_wraps() {
|
||||
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
|
||||
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_range_and_unit_values_are_unchanged() {
|
||||
let p = props(
|
||||
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
|
||||
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
|
||||
);
|
||||
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
|
||||
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
|
||||
}
|
||||
@@ -270,12 +270,3 @@ fn flat_opc_limits_count_what_is_not_kept() {
|
||||
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
|
||||
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
|
||||
}
|
||||
|
||||
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
|
||||
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
|
||||
#[test]
|
||||
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
|
||||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
|
||||
let d = read_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(paras(&d)[0].plain_text(), "a b");
|
||||
}
|
||||
|
||||
@@ -198,9 +198,7 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
|
||||
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
|
||||
continue;
|
||||
}
|
||||
// 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) });
|
||||
atoms.push(lit(c));
|
||||
}
|
||||
let filler = match (o.ignore_punct, o.ignore_space) {
|
||||
(true, true) => r"[\p{P} \t\x{A0}]*",
|
||||
|
||||
@@ -76,11 +76,9 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
|
||||
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
|
||||
let lock = p::bool(v, "lockAspect").unwrap_or(true);
|
||||
let (w0, h0) = obj_size(&o);
|
||||
let (mut w, mut h) = (w0, h0);
|
||||
let (mut w, mut h) = obj_size(&o);
|
||||
let scale = p::f32(v, "scale");
|
||||
let original = p::bool(v, "original").unwrap_or(false);
|
||||
if original {
|
||||
if p::bool(v, "original").unwrap_or(false) {
|
||||
let (ow, oh) =
|
||||
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
|
||||
let k = scale.unwrap_or(100.0) / 100.0;
|
||||
@@ -115,16 +113,7 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
(None, None) => true,
|
||||
_ => lock,
|
||||
};
|
||||
// Sides set in proportion (from the original, or from a size whose sides both have a length)
|
||||
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
|
||||
// leaves as it was keeps its size, a straight line's 0 too.
|
||||
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
|
||||
fit_size(w, h, true)
|
||||
} else {
|
||||
let (fw, fh) = fit_size(w, h, false);
|
||||
let left = |new: f32, old: f32| !original && new == old;
|
||||
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
|
||||
};
|
||||
let (w, h) = fit_size(w, h, keep);
|
||||
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
|
||||
// SmartArt graphic lays itself out again at the new size.
|
||||
let theme = s.doc.settings.theme_colors.clone();
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
|
||||
.params(
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
|
||||
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
|
||||
)
|
||||
.when(has_sizable),
|
||||
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
|
||||
@@ -132,26 +132,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
|
||||
.map(|p| (p.width, p.height))
|
||||
.map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, w, h, crop, .. } = o {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
*media = key.clone();
|
||||
// The replacement keeps the picture's width and takes its own aspect ratio, so a
|
||||
// picture of another shape isn't stretched into the old frame. The old crop
|
||||
// described the old picture, so it is dropped.
|
||||
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
|
||||
let nh = *w * px_h as f32 / px_w as f32;
|
||||
if nh.is_finite() && nh > 0.0 {
|
||||
*h = nh;
|
||||
}
|
||||
}
|
||||
*crop = [0.0; 4];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,43 +77,6 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
|
||||
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
|
||||
}
|
||||
|
||||
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
|
||||
/// of the same physical row. A drop after the break must still stay on the destination page.
|
||||
#[test]
|
||||
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
|
||||
for gap in [0.0, 6.0, 12.0] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
|
||||
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
|
||||
s.run("layout.break", &json!({"kind": "page"})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
|
||||
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
|
||||
super::select::with_float(&mut s, |f| {
|
||||
f.dist = gap;
|
||||
f.dist_top = gap;
|
||||
f.dist_bottom = gap;
|
||||
})
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&s.doc.body).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
|
||||
let hit = object_area(&mut s);
|
||||
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
|
||||
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
|
||||
assert_eq!(s.undo_depth(), depth + 1);
|
||||
let after = serde_json::to_value(&s.doc.body).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
|
||||
s.run("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
|
||||
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
|
||||
let saved = object_area(&mut copy);
|
||||
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
|
||||
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
|
||||
/// select it, then resize and move it with `arrange.bounds`.
|
||||
fn click_resize_and_drag(insert: &str, params: Value) {
|
||||
@@ -1071,66 +1034,6 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
|
||||
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
|
||||
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
|
||||
#[test]
|
||||
fn picture_size_keeps_the_side_it_leaves() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
|
||||
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
|
||||
// A side a scale sets on its own: 0 has no length to scale.
|
||||
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
|
||||
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
|
||||
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
|
||||
let states = [
|
||||
((100.0, 0.0), None),
|
||||
((0.0, 100.0), None),
|
||||
((5000.0, 0.0), None),
|
||||
((0.0, 5000.0), None),
|
||||
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
|
||||
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
|
||||
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
|
||||
];
|
||||
for ((w0, h0), together) in states {
|
||||
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
|
||||
let cases = [
|
||||
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
|
||||
(json!({"width": 200.0}), follow(0, (200.0, h0))),
|
||||
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
|
||||
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
|
||||
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
|
||||
(json!({"scale": 100}), (w0, h0)),
|
||||
(json!({}), (w0, h0)),
|
||||
// The Size dialog: both sides sent, the height as it was.
|
||||
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
|
||||
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
|
||||
];
|
||||
for (v, (ew, eh)) in cases {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &v).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
|
||||
}
|
||||
}
|
||||
// Both sides of a picture's original size fit together, whatever size it had, even when the
|
||||
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
|
||||
let data = super::super::insert::base64_encode(&png(400, 100));
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
|
||||
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
|
||||
}
|
||||
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
|
||||
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
|
||||
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
|
||||
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
|
||||
assert_eq!(size(&s), (200.0, 4000.0));
|
||||
}
|
||||
|
||||
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
|
||||
#[test]
|
||||
fn picture_crop_with_size_is_one_undo_step() {
|
||||
|
||||
@@ -102,9 +102,7 @@ struct SeqCount {
|
||||
|
||||
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
|
||||
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
|
||||
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
|
||||
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
|
||||
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
|
||||
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
let mut count = SeqCount::default();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
@@ -122,11 +120,7 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
|
||||
|
||||
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
|
||||
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
|
||||
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
|
||||
let mut deleted = p.deleted_ranges().into_iter().peekable();
|
||||
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
|
||||
while deleted.next_if(|r| r.end <= off).is_some() {}
|
||||
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
|
||||
for (k, o) in p.objects.iter().enumerate() {
|
||||
for id in o.text_boxes() {
|
||||
if count.boxes.insert(id) {
|
||||
let part = StoryRef::Part(id);
|
||||
@@ -137,9 +131,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
let InlineObject::Field { instr, locked, .. } = o else { continue };
|
||||
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
|
||||
let f = FieldCode::parse(instr);
|
||||
if f.name != "SEQ" || in_deletion {
|
||||
if f.name != "SEQ" {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = f.args.first() else { continue };
|
||||
@@ -155,9 +149,6 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
|
||||
} else if !f.has('c') {
|
||||
n.0 = n.0.saturating_add(1);
|
||||
}
|
||||
if *locked {
|
||||
continue;
|
||||
}
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
|
||||
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
|
||||
count.out.push((story, path.clone(), k, r));
|
||||
|
||||
@@ -549,72 +549,6 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
|
||||
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// A fresh scratch directory for the file-name tests below.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
|
||||
/// name for FILENAME and the new full path for FILENAME \p.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_fields_in_a_header_follow_save_as() {
|
||||
let dir = filename_scratch_dir("save-as");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
let second = dir.join("report_v2.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v1.docx");
|
||||
assert_eq!(got[1].1, first.to_string_lossy());
|
||||
|
||||
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
let got = story_fields(&s, StoryRef::Part(hdr));
|
||||
assert_eq!(got[0].1, "report_v2.docx");
|
||||
assert_eq!(got[1].1, second.to_string_lossy());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
|
||||
/// shows the new name after Save As and Update Field.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
|
||||
let dir = filename_scratch_dir("mergeformat");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
|
||||
let first = dir.join("report_v1.docx");
|
||||
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
|
||||
// The cached text a file written earlier would carry.
|
||||
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
|
||||
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
|
||||
*result = "report_v1.docx".into();
|
||||
}
|
||||
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
|
||||
/// Field; the extension stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filename_field_in_a_header_follows_rename() {
|
||||
let dir = filename_scratch_dir("rename");
|
||||
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
|
||||
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
|
||||
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
|
||||
s.run("references.updateFields", &json!({})).unwrap();
|
||||
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
|
||||
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
|
||||
#[test]
|
||||
@@ -627,7 +561,6 @@ fn quoted_field_argument_backslash_escapes() {
|
||||
}
|
||||
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
|
||||
}
|
||||
|
||||
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
|
||||
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
|
||||
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
|
||||
@@ -793,45 +726,6 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
|
||||
assert_eq!(results, expected);
|
||||
}
|
||||
|
||||
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
|
||||
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
|
||||
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
|
||||
#[test]
|
||||
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
|
||||
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
|
||||
for instr in fields_in {
|
||||
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " "})).unwrap();
|
||||
}
|
||||
let top = wordcraft_doc::Path::top(0);
|
||||
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
|
||||
for (k, o) in para.objects.iter_mut().enumerate() {
|
||||
if let InlineObject::Field { result, locked, .. } = o {
|
||||
*locked = matches!(k, 4 | 6);
|
||||
*result = if *locked { "7" } else { "9" }.into();
|
||||
}
|
||||
}
|
||||
para.touch();
|
||||
// The second Del field is a tracked deletion.
|
||||
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
|
||||
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
|
||||
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
|
||||
let out = output_fields(&s.doc).expect("the stale results change");
|
||||
let shown: Vec<String> = out
|
||||
.para(StoryRef::Body, &top)
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
|
||||
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
|
||||
}
|
||||
|
||||
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
|
||||
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
|
||||
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
|
||||
|
||||
@@ -54,20 +54,11 @@ fn resolve_para(
|
||||
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
|
||||
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
|
||||
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
|
||||
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
|
||||
// mark carries a section break goes whole too: its section joins the following one, as when
|
||||
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
|
||||
if (del.is_some() && accept) || (ins.is_some() && !accept) {
|
||||
let sectioned = story == StoryRef::Body
|
||||
&& path.0.len() == 1
|
||||
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
|
||||
&& crate::cmd::has_next_para(s, story, path);
|
||||
if sectioned {
|
||||
s.doc.remove_section_break(path.last());
|
||||
}
|
||||
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
|
||||
return Ok(());
|
||||
}
|
||||
// of a story or cell can't go, so it just stops being a revision.
|
||||
if ((del.is_some() && accept) || (ins.is_some() && !accept))
|
||||
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
para.mark.ins = None;
|
||||
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
|
||||
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
|
||||
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
|
||||
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
|
||||
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
|
||||
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
|
||||
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
|
||||
|
||||
@@ -8,18 +8,11 @@ use super::{
|
||||
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
|
||||
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
|
||||
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let column = match v.get("column") {
|
||||
None => 0,
|
||||
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
|
||||
};
|
||||
let col = p::u64(v, "column").unwrap_or(0) as usize;
|
||||
let desc = p::bool(v, "descending").unwrap_or(false);
|
||||
let header = p::bool(v, "header");
|
||||
let (tp, _, _) = cell(s)?;
|
||||
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
|
||||
let col = usize::try_from(column)
|
||||
.ok()
|
||||
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
|
||||
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
|
||||
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
|
||||
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
|
||||
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
|
||||
|
||||
@@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
if meta.len() > max_file {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > max_file {
|
||||
return None;
|
||||
}
|
||||
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
|
||||
|
||||
@@ -390,7 +390,7 @@ impl Session {
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
@@ -471,7 +471,7 @@ impl Session {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
view.follow_prefs(&self.prefs);
|
||||
view.read_page_color = self.prefs.read_page_color;
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
|
||||
@@ -32,7 +32,7 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Session {
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.view.follow_prefs(&self.prefs);
|
||||
self.view.read_page_color = self.prefs.read_page_color;
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{Session, cmd};
|
||||
|
||||
mod bidi;
|
||||
mod documents;
|
||||
mod find_apostrophes;
|
||||
mod font_sizes;
|
||||
mod fonts;
|
||||
mod ime_noop;
|
||||
@@ -5747,39 +5746,6 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
|
||||
assert!(device.unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() {
|
||||
let dir = scratch_dir("template-title-fifo");
|
||||
let fifo = dir.join("pipe.dotx");
|
||||
|
||||
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
|
||||
if !made.is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = fifo.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let title = crate::io::package_title(&path, 64 << 20, 64 << 10);
|
||||
let _ = tx.send(title);
|
||||
});
|
||||
|
||||
let result = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
if result.is_err() {
|
||||
// Unblock a stuck reader so the test thread can finish.
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(result.expect("reading a template title blocked on a named pipe"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
|
||||
@@ -10423,73 +10389,6 @@ fn column_texts(s: &Session) -> Vec<String> {
|
||||
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() {
|
||||
for column in [
|
||||
json!(-1),
|
||||
json!(0.5),
|
||||
json!(0.0),
|
||||
json!("0"),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!(1),
|
||||
json!(u32::MAX as u64 + 1),
|
||||
json!(u64::MAX),
|
||||
] {
|
||||
let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]);
|
||||
s.close_typing();
|
||||
run(&mut s, "text.insert", json!({"text": "!"}));
|
||||
let before = s.doc.clone();
|
||||
let selection = s.sel.clone();
|
||||
let dirty = s.dirty;
|
||||
assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}");
|
||||
assert_eq!(s.doc, before, "column {column}");
|
||||
assert_eq!(s.sel, selection, "column {column}");
|
||||
assert_eq!(s.dirty, dirty, "column {column}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_sort_keeps_default_and_valid_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
|
||||
for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() {
|
||||
if k > 0 {
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
}
|
||||
run(&mut s, "text.insert", json!({"text": value}));
|
||||
}
|
||||
let original = s.doc.clone();
|
||||
for params in [json!({"header": false}), json!({"column": 0, "header": false})] {
|
||||
run(&mut s, "table.sort", params);
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, original);
|
||||
}
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true}));
|
||||
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
|
||||
// A column may be missing in some rows. Its present cells still sort normally.
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap();
|
||||
table.rows[0].cells.remove(1);
|
||||
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
|
||||
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
|
||||
|
||||
let mut s = column_table("en-US", &["Title", "3", "1", "2"]);
|
||||
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
|
||||
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true;
|
||||
run(&mut s, "table.sort", json!({"column": 0}));
|
||||
assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]);
|
||||
}
|
||||
|
||||
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
|
||||
#[test]
|
||||
fn table_sort_reads_decimal_comma_in_polish() {
|
||||
@@ -10817,44 +10716,6 @@ fn picture_change_keeps_the_replacement_format() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
|
||||
/// a revised one of a different shape must not stretch it into the old frame.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn picture_change_keeps_width_and_takes_new_aspect() {
|
||||
let dir = scratch_dir("picture-change-aspect");
|
||||
let mut wide = Vec::new();
|
||||
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
|
||||
let mut tall = Vec::new();
|
||||
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
|
||||
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
|
||||
std::fs::write(&first, &wide).unwrap();
|
||||
std::fs::write(&second, &tall).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
|
||||
let size = |s: &Session| {
|
||||
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
|
||||
s.doc.objects_in_reading_order(&mut |o| {
|
||||
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
|
||||
out = (*w, *h, *crop);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let (w0, h0, _) = size(&s);
|
||||
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
|
||||
run(&mut s, "picture.crop", json!({"left": 0.1}));
|
||||
let (w_cropped, _, crop) = size(&s);
|
||||
assert!(crop[0] > 0.0, "cropped before the change");
|
||||
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
|
||||
let (w1, h1, crop) = size(&s);
|
||||
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
|
||||
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
|
||||
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_open_repairs_hostile_run_lengths() {
|
||||
use wordcraft_doc::{Block, Document};
|
||||
@@ -12858,48 +12719,3 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
|
||||
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
|
||||
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
|
||||
}
|
||||
|
||||
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
|
||||
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
|
||||
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
|
||||
fn sectioned_revision(delete: bool) -> Session {
|
||||
use wordcraft_doc::{Revision, RevisionKind};
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody"));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}}));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert };
|
||||
s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None });
|
||||
s.doc.last_section.margin_left = 72.0;
|
||||
let mut sect = s.doc.last_section.clone();
|
||||
sect.margin_left = 144.0;
|
||||
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
let len = p.len();
|
||||
p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap();
|
||||
if delete {
|
||||
p.mark.del = Some(0)
|
||||
} else {
|
||||
p.mark.ins = Some(0)
|
||||
}
|
||||
p.props.indent_left = Some(36.0);
|
||||
p.section = Some(Box::new(sect));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() {
|
||||
// #5667: the resolved paragraph was left empty but kept for its section break, so the next
|
||||
// paragraph joined it and took its properties (Normal, its indent). It goes whole instead,
|
||||
// as an empty paragraph without a break does, and its section joins the following one.
|
||||
for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] {
|
||||
let mut s = sectioned_revision(delete);
|
||||
assert_eq!(s.doc.sections().len(), 2);
|
||||
run(&mut s, cmd, json!({}));
|
||||
assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}");
|
||||
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
|
||||
assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style");
|
||||
assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent");
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it");
|
||||
assert_eq!(s.doc.sections().len(), 1, "{cmd}");
|
||||
assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,51 +346,3 @@ fn input_method_joins_stay_with_their_window() {
|
||||
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
|
||||
drop(a);
|
||||
}
|
||||
|
||||
/// Check spelling as you type is one setting for the app, not one per open document (#5740):
|
||||
/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off
|
||||
/// in the others too, and switching to another document doesn't turn it back on, so what is saved
|
||||
/// for the next run is what was chosen, whichever document is active then.
|
||||
#[test]
|
||||
fn check_spelling_is_one_setting_for_every_open_document() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let b = s.document_id();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let c = s.document_id();
|
||||
assert!(s.view.proofing);
|
||||
s.activate(a).unwrap();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
for id in [b, c, a] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(!s.view.proofing, "off in document {id}");
|
||||
assert!(!s.prefs.check_spelling);
|
||||
}
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
for id in [b, c] {
|
||||
s.activate(id).unwrap();
|
||||
assert!(s.view.proofing, "back on in document {id}");
|
||||
}
|
||||
// A document opened now starts with the setting as it is.
|
||||
run(&mut s, "review.proofing", json!({"value": false}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert!(!s.view.proofing);
|
||||
}
|
||||
|
||||
/// A window's view put back after Check spelling as you type changed in another window takes the
|
||||
/// setting as it is now, not as it was when the window's view was taken (#5740): every view made
|
||||
/// active follows the saved preference.
|
||||
#[test]
|
||||
fn a_window_view_put_back_follows_the_spelling_setting() {
|
||||
let mut s = s();
|
||||
assert!(s.view.proofing);
|
||||
let held = s.take_view();
|
||||
run(&mut s, "file.options", json!({"checkSpelling": false}));
|
||||
s.put_view(held);
|
||||
assert!(!s.view.proofing, "the view taken with spelling on comes back with it off");
|
||||
let held = s.take_view();
|
||||
run(&mut s, "review.proofing", json!({"value": true}));
|
||||
s.put_view(held);
|
||||
assert!(s.view.proofing);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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,43 +469,3 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() {
|
||||
assert_eq!(m[0].shown_as, [shown], "{m:?}");
|
||||
assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed");
|
||||
}
|
||||
|
||||
/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any
|
||||
/// candidate is installed, and substitutes by candidate family when none is.
|
||||
#[test]
|
||||
fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]);
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(doc.clone());
|
||||
assert!(m.is_empty(), "expected not to be reported as missing: {m:?}");
|
||||
}
|
||||
let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]);
|
||||
assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed");
|
||||
|
||||
// Roundtrip through DOCX export and import:
|
||||
let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap();
|
||||
let loaded = wordcraft_docx::read(&docx_bytes).unwrap();
|
||||
assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial"));
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let m = missing_in(loaded);
|
||||
assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}");
|
||||
}
|
||||
|
||||
// When none of the fonts is installed, missing_fonts flags it with an appropriate substitute:
|
||||
if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") {
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;Times New Roman");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}");
|
||||
}
|
||||
let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")]));
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123");
|
||||
let drawn = m[0].shown_as.first().expect("drawn face");
|
||||
assert!(
|
||||
drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia",
|
||||
"drawn: {drawn}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,19 +9,6 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading.
|
||||
/// Deleting the empty heading mark keeps the next paragraph as body text.
|
||||
#[test]
|
||||
fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph."));
|
||||
run(&mut s, "para.style", json!({"style": "Heading1"}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.text, "Body paragraph.");
|
||||
assert_eq!(p.props.style, None);
|
||||
assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]);
|
||||
}
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
|
||||
@@ -161,17 +161,6 @@ fn default_read_zoom() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
impl ViewState {
|
||||
/// Take the app-wide settings a view keeps a copy of from the saved preferences: Read Mode's
|
||||
/// page colour and Check spelling as you type (#5740). A view made active goes through this
|
||||
/// (a document switched to, opened or made, a window's view put back), so a change made while
|
||||
/// another view was active holds in this one too.
|
||||
pub(crate) fn follow_prefs(&mut self, prefs: &crate::prefs::Prefs) {
|
||||
self.read_page_color = prefs.read_page_color;
|
||||
self.proofing = prefs.check_spelling;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
ViewState {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
use wordcraft_engine::{Selection, Session};
|
||||
use wordcraft_layout::outline;
|
||||
|
||||
/// Dispatch an engine command and report its ID if it fails.
|
||||
fn run(s: &mut Session, id: &str, params: Value) {
|
||||
s.run(id, ¶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}");
|
||||
}
|
||||
+7
-55
@@ -21,7 +21,7 @@ pub use fontdb::{
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, ShapePlan, ShapePlanKey, Shaper, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
pub use script::{fallback_rank, is_emoji, joins_cursively, needs_layout_tables, script_fallbacks};
|
||||
pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
pub use ui_fallback::{
|
||||
@@ -185,34 +185,13 @@ fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
shape_in(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// The text shown on either side of what is being shaped. Only a letter's form depends on it: the
|
||||
/// letters of an Arabic word join across a change of formatting, so each piece has to know what
|
||||
/// it continues (#4093). Nothing outside the shaped text is drawn or measured.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> ShapeContext<'a> {
|
||||
/// This context for the part `r` of `text`: what's beyond `text` where `r` reaches its edge.
|
||||
fn at_edges_of(self, text: &str, r: &std::ops::Range<usize>) -> ShapeContext<'a> {
|
||||
ShapeContext { before: if r.start == 0 { self.before } else { "" }, after: if r.end == text.len() { self.after } else { "" } }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`shape`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, around: ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false, around);
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
}
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let Some(sub) = text.get(r.clone()) else { continue };
|
||||
let mut g = shape_run_in(face, sub, features, &map, rtl, around.at_edges_of(text, &r));
|
||||
let mut g = shape_run(face, sub, features, &map, rtl);
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
@@ -226,19 +205,7 @@ pub fn shape_in(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(
|
||||
/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order,
|
||||
/// each cluster's glyphs in the shaper's (visual) order.
|
||||
pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
shape_run_in(face, text, features, map, rtl, ShapeContext::default())
|
||||
}
|
||||
|
||||
/// [`shape_run`] of a piece of longer text (see [`ShapeContext`]).
|
||||
pub fn shape_run_in(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let g = shape_dir(face, text, features, &map, rtl, around);
|
||||
let g = shape_dir(face, text, features, &map, rtl);
|
||||
if !rtl {
|
||||
return g;
|
||||
}
|
||||
@@ -254,14 +221,7 @@ pub fn shape_run_in(
|
||||
groups.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
fn shape_dir(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: &impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
around: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
@@ -274,10 +234,6 @@ fn shape_dir(
|
||||
for (i, c) in sub.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
// After the text: adding to a buffer clears what follows it.
|
||||
let around = around.at_edges_of(text, &r);
|
||||
buf.set_pre_context(around.before);
|
||||
buf.set_post_context(around.after);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let plan = shape_plan(face, &shaper, &buf, features);
|
||||
@@ -430,10 +386,7 @@ mod tests {
|
||||
gb.glyph_infos().iter().zip(gb.glyph_positions()).map(|(i, p)| (i.glyph_id, i.cluster, p.x_advance, p.x_offset, p.y_offset)).collect()
|
||||
};
|
||||
let kept = |face: &FontFace, text: &str, features: &[Feature], rtl: bool| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
shape_dir(face, text, features, &|c| c, rtl, ShapeContext::default())
|
||||
.iter()
|
||||
.map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset))
|
||||
.collect()
|
||||
shape_dir(face, text, features, &|c| c, rtl).iter().map(|g| (g.gid, g.cluster as u32, g.x_advance, g.x_offset, g.y_offset)).collect()
|
||||
};
|
||||
let db = FontDb::global();
|
||||
let faces = [db.face(DEFAULT_FAMILY, "Regular"), db.face(DEFAULT_FAMILY, "Italic"), db.face("Source Sans 3", "Regular")];
|
||||
@@ -468,8 +421,7 @@ mod tests {
|
||||
for n in 0..3 * MAX_SHAPE_PLANS as u32 {
|
||||
let features = [feature_value(b"kern", n % 2), feature_value(b"liga", n)];
|
||||
let rtl = n % 3 == 0;
|
||||
let kept: Vec<(u32, i32)> =
|
||||
shape_dir(&face, "office", &features, &|c| c, rtl, ShapeContext::default()).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
let kept: Vec<(u32, i32)> = shape_dir(&face, "office", &features, &|c| c, rtl).iter().map(|g| (g.gid, g.x_advance)).collect();
|
||||
assert_eq!(kept, fresh(&features, rtl), "{n}");
|
||||
assert!(kept_shape_plans() <= MAX_SHAPE_PLANS, "{}", kept_shape_plans());
|
||||
}
|
||||
|
||||
@@ -18,28 +18,6 @@ fn real_script(c: char) -> Option<Script> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` belongs to a script whose letters join their neighbours (Arabic, Syriac, Mongolian…),
|
||||
/// so a letter's form depends on the text on either side of it.
|
||||
pub fn joins_cursively(c: char) -> bool {
|
||||
c >= '\u{0600}'
|
||||
&& matches!(
|
||||
c.script(),
|
||||
Script::Adlam
|
||||
| Script::Arabic
|
||||
| Script::Chorasmian
|
||||
| Script::Hanifi_Rohingya
|
||||
| Script::Mandaic
|
||||
| Script::Manichaean
|
||||
| Script::Mongolian
|
||||
| Script::Nko
|
||||
| Script::Old_Uyghur
|
||||
| Script::Phags_Pa
|
||||
| Script::Psalter_Pahlavi
|
||||
| Script::Sogdian
|
||||
| Script::Syriac
|
||||
)
|
||||
}
|
||||
|
||||
/// Maximal single-script byte ranges of `text`. Characters of no particular script join the run
|
||||
/// before them (or, at the start, the first run), so the ranges cover `text` exactly.
|
||||
pub(crate) fn script_runs(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||
|
||||
+21
-377
@@ -32,27 +32,12 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
/// as Liberation Serif for Times New Roman, or the same design under another name), rather than
|
||||
/// the generic lists any unknown font gets?
|
||||
pub fn has_named_substitutes(family: &str) -> bool {
|
||||
if family.contains(';') {
|
||||
return list_class(family).named;
|
||||
}
|
||||
substitutes_by_name(charset_base_family(family)).1
|
||||
}
|
||||
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
|
||||
/// [`substitutes`], and whether they were chosen for this family by name.
|
||||
fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
if family.contains(';') {
|
||||
for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let (subs, named) = substitutes_by_name(cand);
|
||||
if named || is_thai_family(cand) || subs != SANS {
|
||||
return (subs, named);
|
||||
}
|
||||
}
|
||||
if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) {
|
||||
return substitutes_by_name(first);
|
||||
}
|
||||
}
|
||||
const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"];
|
||||
const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"];
|
||||
const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"];
|
||||
const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"];
|
||||
@@ -172,11 +157,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) {
|
||||
/// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's
|
||||
/// PDFs give the fonts (`/Ascent`, `/Descent`).
|
||||
pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() {
|
||||
match family.trim().to_ascii_lowercase().as_str() {
|
||||
"symbol" | "symbol (pcl6)" => Some((1.005, 0.216)),
|
||||
"wingdings" => Some((0.899, 0.205)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the
|
||||
@@ -239,10 +224,7 @@ fn metric_twins(family: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
/// wraps and paginates as in `family`.
|
||||
pub fn metric_compatible(family: &str, substitute: &str) -> bool {
|
||||
let s = substitute.trim();
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let base = charset_base_family(f);
|
||||
base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s))
|
||||
})
|
||||
metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common
|
||||
@@ -374,63 +356,10 @@ fn is_thai_serif(f: &str) -> bool {
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
if family.contains(';') {
|
||||
return list_class(family).thai;
|
||||
}
|
||||
let f = family.trim().to_ascii_lowercase();
|
||||
let f = family.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks
|
||||
/// them for every piece of every run set in the list, so the answer is worked out once per list
|
||||
/// rather than once per piece.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ListClass {
|
||||
/// [`is_thai_family`].
|
||||
thai: bool,
|
||||
/// [`has_named_substitutes`].
|
||||
named: bool,
|
||||
}
|
||||
|
||||
/// [`ListClass`] of the semicolon-separated `family`, memoised by its text.
|
||||
fn list_class(family: &str) -> ListClass {
|
||||
let key = family_list_key(family);
|
||||
let generation = FontDb::global().generation();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached, class)) = c.classes.get(&key)
|
||||
&& cached == family
|
||||
{
|
||||
return *class;
|
||||
}
|
||||
}
|
||||
let class = ListClass { thai: classify_thai(family), named: classify_named(family) };
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.classes.len() >= FAMILY_CACHE_MAX {
|
||||
c.classes.clear();
|
||||
}
|
||||
c.classes.insert(key, (family.to_string(), class));
|
||||
}
|
||||
class
|
||||
}
|
||||
|
||||
/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers.
|
||||
fn classify_thai(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| {
|
||||
let f = f.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`]
|
||||
/// answers.
|
||||
fn classify_named(family: &str) -> bool {
|
||||
family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1)
|
||||
}
|
||||
|
||||
/// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's
|
||||
/// own and Noto's. Matched by name only.
|
||||
fn is_khmer(f: &str) -> bool {
|
||||
@@ -487,168 +416,8 @@ fn resolve_cache() -> &'static Mutex<Memo> {
|
||||
}
|
||||
|
||||
const FAMILY_CACHE_MAX: usize = 4096;
|
||||
/// Family names longer than this (bytes) are resolved but never remembered as string keys.
|
||||
/// Family names longer than this (bytes) are resolved but never remembered.
|
||||
const FAMILY_KEY_MAX: usize = 256;
|
||||
/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list
|
||||
/// for candidate names not present in [`FontDb::families`].
|
||||
const MAX_DIRECT_CANDIDATE_PROBES: usize = 32;
|
||||
|
||||
/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`].
|
||||
#[derive(Default)]
|
||||
struct FallbackListMemo {
|
||||
generation: u64,
|
||||
families: std::collections::HashSet<String>,
|
||||
probed: HashMap<String, bool>,
|
||||
lists: HashMap<(u64, usize), (String, (String, bool))>,
|
||||
long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>,
|
||||
classes: HashMap<(u64, usize), (String, ListClass)>,
|
||||
}
|
||||
|
||||
/// Shared cache for fallback-list resolutions and candidate availability checks.
|
||||
fn fallback_list_cache() -> &'static Mutex<FallbackListMemo> {
|
||||
static CACHE: OnceLock<Mutex<FallbackListMemo>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default()))
|
||||
}
|
||||
|
||||
/// Hash and byte length of `family`, used as a compact cache key for fallback lists.
|
||||
fn family_list_key(family: &str) -> (u64, usize) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
family.hash(&mut h);
|
||||
(h.finish(), family.len())
|
||||
}
|
||||
|
||||
/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded
|
||||
/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate.
|
||||
fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool {
|
||||
let cand = cand.trim();
|
||||
if cand.is_empty() || cand.len() > FAMILY_KEY_MAX {
|
||||
return false;
|
||||
}
|
||||
let generation = db.generation();
|
||||
let lower = cand.to_ascii_lowercase();
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
}
|
||||
if c.families.is_empty() {
|
||||
c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect();
|
||||
}
|
||||
if c.families.contains(&lower) {
|
||||
return true;
|
||||
}
|
||||
if let Some(&hit) = c.probed.get(&lower) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
if *direct_probes == 0 {
|
||||
return false;
|
||||
}
|
||||
*direct_probes -= 1;
|
||||
let hit = db.has_family(cand);
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.probed.len() >= FAMILY_CACHE_MAX {
|
||||
c.probed.clear();
|
||||
}
|
||||
c.probed.insert(lower, hit);
|
||||
}
|
||||
hit
|
||||
}
|
||||
|
||||
/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list.
|
||||
fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.lists.len() >= FAMILY_CACHE_MAX {
|
||||
c.lists.clear();
|
||||
}
|
||||
c.lists.insert(key, (family.to_string(), result.clone()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`.
|
||||
fn resolve_family(db: &FontDb, family: &str) -> (String, bool) {
|
||||
if !family.contains(';') {
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
return if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
}
|
||||
|
||||
let generation = db.generation();
|
||||
let list_key = family_list_key(family);
|
||||
{
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.lists.get(&list_key)
|
||||
&& cached_family == family
|
||||
{
|
||||
return r.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect();
|
||||
if candidates.is_empty() {
|
||||
return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true));
|
||||
}
|
||||
|
||||
let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
|
||||
// 1. First installed family in the list:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if candidate_has_family(db, cand, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (cand.to_string(), false));
|
||||
}
|
||||
if base != cand && candidate_has_family(db, base, &mut direct_probes) {
|
||||
return remember_list_resolution(generation, list_key, family, (base.to_string(), false));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito.
|
||||
// Try Thai substitutes before metric twins of later candidates:
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
if is_thai_family(base)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES;
|
||||
if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) {
|
||||
return remember_list_resolution(generation, list_key, family, (twin.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono):
|
||||
for &cand in &candidates {
|
||||
let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) };
|
||||
let (subs, named) = substitutes_by_name(base);
|
||||
if (named || is_thai_family(base) || subs != SANS)
|
||||
&& let Some(sub) = substitute(db, base)
|
||||
{
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first candidate's substitute:
|
||||
if let Some(&first) = candidates.first() {
|
||||
let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) };
|
||||
if let Some(sub) = substitute(db, base) {
|
||||
return remember_list_resolution(generation, list_key, family, (sub.to_string(), true));
|
||||
}
|
||||
}
|
||||
|
||||
remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true))
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
@@ -658,10 +427,6 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
let generation = db.generation();
|
||||
let cacheable = family.len() <= FAMILY_KEY_MAX;
|
||||
let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic);
|
||||
let long_key = (!cacheable && family.contains(';')).then(|| {
|
||||
let (h, len) = family_list_key(family);
|
||||
(h, len, bold, italic)
|
||||
});
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 != generation {
|
||||
@@ -671,35 +436,24 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation != generation {
|
||||
*c = FallbackListMemo { generation, ..Default::default() };
|
||||
} else if let Some((cached_family, r)) = c.long_resolved.get(&lk)
|
||||
&& cached_family == family
|
||||
{
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let (fam, substituted) = resolve_family(db, family);
|
||||
// Printer-driver Symbol aliases use the same encoding as Symbol.
|
||||
let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) };
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else if base != family && db.has_family(base) {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
if cacheable {
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
} else if let Some(lk) = long_key {
|
||||
let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if c.generation == generation {
|
||||
if c.long_resolved.len() >= FAMILY_CACHE_MAX {
|
||||
c.long_resolved.clear();
|
||||
}
|
||||
c.long_resolved.insert(lk, (family.to_string(), r));
|
||||
let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cacheable && c.0 == generation {
|
||||
if c.1.len() >= FAMILY_CACHE_MAX {
|
||||
c.1.clear();
|
||||
}
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -1033,114 +787,4 @@ mod tests {
|
||||
assert!(!has_named_substitutes(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Semicolon-separated fallback lists resolve to the first installed family, or to a
|
||||
/// candidate's substitute when no listed family is installed.
|
||||
#[test]
|
||||
fn libreoffice_fallback_list_resolves_first_installed_family() {
|
||||
let db = FontDb::global();
|
||||
if db.has_family("Liberation Sans") || db.has_family("Arial") {
|
||||
let r = resolve("Liberation Sans;Arial", false, false);
|
||||
assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family);
|
||||
if db.has_family("Liberation Sans") {
|
||||
assert_eq!(r.face.family, "Liberation Sans");
|
||||
} else {
|
||||
assert_eq!(r.face.family, "Arial");
|
||||
}
|
||||
}
|
||||
let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false);
|
||||
assert!(!bundled.substituted);
|
||||
assert_eq!(bundled.face.family, "Source Serif 4");
|
||||
|
||||
// When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito:
|
||||
if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") {
|
||||
let r = resolve("NonExistentSansFace123;Times New Roman", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman");
|
||||
assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family);
|
||||
}
|
||||
let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false);
|
||||
assert!(r.substituted);
|
||||
assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family");
|
||||
assert!(
|
||||
r.face.family.contains("Serif")
|
||||
|| r.face.family == "Times"
|
||||
|| r.face.family == "Times New Roman"
|
||||
|| r.face.family == "Tinos"
|
||||
|| r.face.family == "Caladea"
|
||||
|| r.face.family == "Georgia",
|
||||
"got {:?}",
|
||||
r.face.family
|
||||
);
|
||||
|
||||
// Metric compatibility of fallback list:
|
||||
assert!(metric_compatible("Open Sans;Arial", "Liberation Sans"));
|
||||
assert!(metric_compatible("Liberation Sans;Arial", "Helvetica"));
|
||||
assert!(!metric_compatible("Open Sans;Arial", "Carlito"));
|
||||
}
|
||||
|
||||
/// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an
|
||||
/// earlier Thai substitute takes precedence over a later candidate's metric twin.
|
||||
#[test]
|
||||
fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() {
|
||||
let db = FontDb::global();
|
||||
let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else {
|
||||
eprintln!("skipped: no Thai substitute installed");
|
||||
return;
|
||||
};
|
||||
if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) {
|
||||
let r = resolve("Cordia New;Arial", false, false);
|
||||
assert!(r.substituted);
|
||||
assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family);
|
||||
}
|
||||
if let Some(latin) =
|
||||
METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t)))
|
||||
{
|
||||
let list = format!("NonExistentThaiUPC;{latin}");
|
||||
let r = resolve(&list, false, false);
|
||||
assert!(r.substituted);
|
||||
assert_eq!(r.face.family, thai_sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and
|
||||
/// cache their resolution without growing `resolve_cache` string keys.
|
||||
#[test]
|
||||
fn long_fallback_list_selects_later_installed_family_and_caches() {
|
||||
let mut candidates: Vec<String> = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("Source Serif 4".to_string());
|
||||
let list = candidates.join(";");
|
||||
assert!(list.len() > FAMILY_KEY_MAX);
|
||||
|
||||
let r1 = resolve(&list, false, false);
|
||||
let r2 = resolve(&list, false, false);
|
||||
assert!(!r1.substituted);
|
||||
assert_eq!(r1.face.family, "Source Serif 4");
|
||||
assert_eq!(r1.face.id(), r2.face.id());
|
||||
assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX));
|
||||
}
|
||||
|
||||
/// The name-based predicates look at every candidate, and their answer is cached per list: a
|
||||
/// later Thai or named fallback still counts, and a list is classified once rather than for
|
||||
/// every piece of text shaped in it.
|
||||
#[test]
|
||||
fn list_classification_reads_every_candidate_and_is_cached() {
|
||||
// The Thai family is last, past any shorter bound, and still classifies the list.
|
||||
let mut candidates: Vec<String> = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect();
|
||||
candidates.push("TH Sarabun New".to_string());
|
||||
let thai_list = candidates.join(";");
|
||||
assert!(is_thai_family(&thai_list), "a later Thai family still counts");
|
||||
|
||||
// A named substitute at the end classifies too (Times New Roman has Liberation Serif).
|
||||
candidates.pop();
|
||||
candidates.push("Times New Roman".to_string());
|
||||
let named_list = candidates.join(";");
|
||||
assert!(has_named_substitutes(&named_list), "a later named fallback still counts");
|
||||
|
||||
// Both are memoised by list text: a second ask answers without rescanning, and a list
|
||||
// whose text differs only after the key's hash is not mistaken for the cached one.
|
||||
assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list));
|
||||
let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len();
|
||||
assert!(cached >= 2, "{cached} lists classified");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,7 @@ pub(super) fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
|
||||
for r in rows {
|
||||
let mut cells = Vec::new();
|
||||
let mut g = 0usize;
|
||||
// A document row keeps MAX_COLS cells; the rest would only widen `covering`, which is
|
||||
// walked once per row.
|
||||
for cell_toks in r.into_iter().take(wordcraft_doc::table::MAX_COLS) {
|
||||
for cell_toks in r {
|
||||
let mut cell = Cell::default();
|
||||
let mut content = trim_cell(cell_toks);
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
|
||||
|
||||
@@ -460,14 +460,3 @@ fn ligature_characters_split_across_runs_stay_apart() {
|
||||
let back = import(tex.as_bytes());
|
||||
assert_eq!(back.plain_text(Default::default()).trim(), "a--b ''x", "{tex}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tabular_row_keeps_the_cells_a_table_row_holds() {
|
||||
// A 5,000-cell row with spans widened the multirow tracking every later row walks; the
|
||||
// document keeps MAX_COLS cells a row, so the rest are left out while parsing.
|
||||
let wide = vec!["\\multicolumn{63}{c}{x}"; 5000].join("&");
|
||||
let toks = tokenize(&format!("{wide}\\\\ a & b \\\\"));
|
||||
let t = table::table(&toks, "", 0);
|
||||
assert_eq!(t.rows.first().map(Vec::len), Some(wordcraft_doc::table::MAX_COLS));
|
||||
assert_eq!(t.rows.get(1).map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
@@ -323,13 +323,11 @@ fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters. `have` is `s`'s character
|
||||
/// count, kept up to date here: counting `s` again for every piece of text made a full cell
|
||||
/// cost its length per further text event.
|
||||
fn push_capped(s: &mut String, have: &mut usize, t: &str) {
|
||||
for c in t.chars().take(MAX_CELL_CHARS.saturating_sub(*have)) {
|
||||
s.push(c);
|
||||
*have += 1;
|
||||
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
|
||||
fn push_capped(s: &mut String, t: &str) {
|
||||
let have = s.chars().count();
|
||||
if have < MAX_CELL_CHARS {
|
||||
s.extend(t.chars().take(MAX_CELL_CHARS - have));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,27 +427,6 @@ 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>"#;
|
||||
@@ -467,22 +444,6 @@ mod tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_cell_takes_further_text_without_recounting() {
|
||||
let (mut s, mut n) = (String::new(), 0usize);
|
||||
push_capped(&mut s, &mut n, "é");
|
||||
push_capped(&mut s, &mut n, &"x".repeat(MAX_CELL_CHARS));
|
||||
assert_eq!((s.chars().count(), n), (MAX_CELL_CHARS, MAX_CELL_CHARS));
|
||||
// A million more text events on the full cell each cost nothing (they used to count it).
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..1_000_000 {
|
||||
push_capped(&mut s, &mut n, "y");
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(s.chars().count(), MAX_CELL_CHARS);
|
||||
assert!(!s.contains('y'));
|
||||
}
|
||||
|
||||
/// #1287: one long shared string in thousands of cells stays under the text budget.
|
||||
#[test]
|
||||
fn xlsx_shared_strings_cannot_blow_up_memory() {
|
||||
|
||||
@@ -11,8 +11,6 @@ struct OCell {
|
||||
kind: String,
|
||||
value: Option<String>,
|
||||
text: String,
|
||||
/// Characters in `text` (see [`push_capped`]).
|
||||
text_chars: usize,
|
||||
paras: usize,
|
||||
}
|
||||
|
||||
@@ -79,7 +77,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
{
|
||||
if c.paras > 0 {
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
push_capped(&mut c.text, "\n");
|
||||
}
|
||||
c.paras += 1;
|
||||
}
|
||||
@@ -88,17 +86,17 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
b"s" if in_p > 0 && skip == 0 => {
|
||||
let k = attr(e, b"c").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, 1_000);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &mut c.text_chars, &" ".repeat(k));
|
||||
push_capped(&mut c.text, &" ".repeat(k));
|
||||
}
|
||||
}
|
||||
b"tab" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\t");
|
||||
push_capped(&mut c.text, "\t");
|
||||
}
|
||||
}
|
||||
b"line-break" if in_p > 0 && skip == 0 => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
push_capped(&mut c.text, &mut c.text_chars, "\n");
|
||||
push_capped(&mut c.text, "\n");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -109,7 +107,7 @@ pub(super) fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>,
|
||||
&& skip == 0
|
||||
&& let Some(c) = cell.as_mut()
|
||||
{
|
||||
push_capped(&mut c.text, &mut c.text_chars, &t);
|
||||
push_capped(&mut c.text, &t);
|
||||
}
|
||||
}
|
||||
Ev::End(n) => {
|
||||
@@ -183,7 +181,7 @@ fn ods_value(c: &OCell) -> String {
|
||||
("string", Some(v)) if c.text.trim().is_empty() => {
|
||||
// Attribute text obeys the same per-cell cap as `<text:p>` text.
|
||||
let mut s = String::new();
|
||||
push_capped(&mut s, &mut 0, v);
|
||||
push_capped(&mut s, v);
|
||||
s
|
||||
}
|
||||
_ => c.text.clone(),
|
||||
|
||||
@@ -83,13 +83,14 @@ fn workbook(pkg: &mut Package<'_>) -> Result<Workbook, String> {
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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 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();
|
||||
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") })
|
||||
}
|
||||
@@ -102,18 +103,18 @@ pub(super) fn xlsx_sheets(pkg: &mut Package<'_>) -> Result<Vec<SheetRef>, String
|
||||
/// (`rPh`) left out.
|
||||
fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<(String, usize)> = None;
|
||||
let mut cur: Option<String> = None;
|
||||
let (mut in_t, mut phonetic) = (false, 0usize);
|
||||
walk(xml, |ev| match ev {
|
||||
Ev::Start(e) => match e.local_name().as_ref() {
|
||||
b"si" => cur = Some((String::new(), 0)),
|
||||
b"si" => cur = Some(String::new()),
|
||||
b"t" => in_t = true,
|
||||
b"rPh" => phonetic += 1,
|
||||
_ => {}
|
||||
},
|
||||
Ev::End(n) => match n.as_slice() {
|
||||
b"si" => {
|
||||
if let Some((s, _)) = cur.take()
|
||||
if let Some(s) = cur.take()
|
||||
&& out.len() < MAX_SHARED
|
||||
{
|
||||
out.push(s);
|
||||
@@ -126,9 +127,9 @@ fn shared_strings(xml: &[u8]) -> Vec<String> {
|
||||
Ev::Text(t) => {
|
||||
if in_t
|
||||
&& phonetic == 0
|
||||
&& let Some((s, n)) = cur.as_mut()
|
||||
&& let Some(s) = cur.as_mut()
|
||||
{
|
||||
push_capped(s, n, &t);
|
||||
push_capped(s, &t);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -182,9 +183,6 @@ struct XCell {
|
||||
style: usize,
|
||||
value: String,
|
||||
inline: String,
|
||||
/// Characters in `value` and `inline` (see [`push_capped`]).
|
||||
value_chars: usize,
|
||||
inline_chars: usize,
|
||||
}
|
||||
|
||||
pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<String>>, String> {
|
||||
@@ -230,9 +228,9 @@ pub(super) fn xlsx_rows(pkg: &mut Package<'_>, path: &str) -> Result<Vec<Vec<Str
|
||||
Ev::Text(t) => {
|
||||
if let Some(c) = cell.as_mut() {
|
||||
if in_v {
|
||||
push_capped(&mut c.value, &mut c.value_chars, &t);
|
||||
push_capped(&mut c.value, &t);
|
||||
} else if in_is_t && phonetic == 0 {
|
||||
push_capped(&mut c.inline, &mut c.inline_chars, &t);
|
||||
push_capped(&mut c.inline, &t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-17
@@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool {
|
||||
let layout = table::layout_table(ctx, story, t, path, width, depth + 1);
|
||||
let (tx, ty) = match t.props.float {
|
||||
Some(float) => {
|
||||
let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t));
|
||||
let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 };
|
||||
let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables);
|
||||
exclusions.push((area, false, None, WrapText::BothSides, true));
|
||||
float_tables.push(area);
|
||||
@@ -366,12 +366,8 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
|
||||
items.splice(at..at, back);
|
||||
items.extend(front);
|
||||
}
|
||||
// Automatic space after: none after the last paragraph of a cell or box. Nor
|
||||
// contextual spacing after a cell's last paragraph in the default style (or an
|
||||
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
|
||||
let last = i + 1 == blocks.len();
|
||||
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
|
||||
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
|
||||
// Automatic space after: none after the last paragraph of a cell or box.
|
||||
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
|
||||
y += edges.top + pl.height + edges.bottom + after;
|
||||
prev_after = after;
|
||||
prev_after_auto = pl.rp.after_auto;
|
||||
@@ -468,15 +464,14 @@ pub(crate) struct BoxLayout {
|
||||
|
||||
/// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and
|
||||
/// the area text keeps clear of. Positions relative to the page or its margins need the box's
|
||||
/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's
|
||||
/// left margin before Word 2013 layout, `None` in it.
|
||||
/// `frame`; without one (a table cell) they're relative to the box.
|
||||
fn box_float_table_rect(
|
||||
frame: Option<PageFrame>,
|
||||
width: f32,
|
||||
y: f32,
|
||||
tl: &table::TableLayout,
|
||||
f: &TableFloat,
|
||||
legacy: Option<f32>,
|
||||
legacy: f32,
|
||||
float_tables: &[Rect],
|
||||
) -> (Rect, Rect) {
|
||||
let h = tl.rows.iter().map(|r| r.height).sum::<f32>().clamp(0.0, 100_000.0);
|
||||
@@ -512,19 +507,18 @@ fn box_float_table_rect(
|
||||
};
|
||||
// Nor is there an area to align in: the table starts where the text is.
|
||||
let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align };
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables);
|
||||
let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 };
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables);
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
(r, r.inset(-dl, -dt, -dr, -db))
|
||||
}
|
||||
|
||||
/// Floating table `f` at `r`, moved below the floating tables in its way when it may not
|
||||
/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from
|
||||
/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first
|
||||
/// cell has no left margin.
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect {
|
||||
/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's
|
||||
/// left edge (`text_left`).
|
||||
pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect {
|
||||
let [dl, dt, dr, db] = f.dist_from_text();
|
||||
let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column);
|
||||
for _ in 0..64 {
|
||||
let area = r.inset(-dl, -dt, -dr, -db);
|
||||
let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break };
|
||||
|
||||
@@ -4,12 +4,9 @@ use super::headers::HfLinks;
|
||||
use super::placed::Placed;
|
||||
use super::{
|
||||
Anchor, Arc, Block, CharProps, Counters, DocLayout, Document, FieldCtx, Float, HashMap, HashSet, HideDeleted, InlineObject, LayoutOptions, Level,
|
||||
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
ParaLayout, Paragraph, Rect, Rgb, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
|
||||
};
|
||||
|
||||
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
|
||||
const LINE_NUMBER_STYLE: &str = "LineNumber";
|
||||
|
||||
/// A layout pass: finished, or to be done again with the page totals it found, or with the
|
||||
/// floats placed above their anchors it found ([`EarlyFloats`]).
|
||||
pub(crate) enum Pass {
|
||||
@@ -196,11 +193,10 @@ impl Ctx<'_> {
|
||||
if let Some(p) = self.numbers.get(&n) {
|
||||
return p.clone();
|
||||
}
|
||||
// Word formats line numbers with the "Line Number" character style over the document's
|
||||
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
|
||||
// Aptos 12 pt), not in a fixed small grey.
|
||||
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
|
||||
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
|
||||
let mut p = Paragraph::with_text(
|
||||
&n.to_string(),
|
||||
CharProps { size: Some(10.0), color: Some(wordcraft_doc::TextColor::Rgb(Rgb(0x60, 0x60, 0x60))), ..Default::default() },
|
||||
);
|
||||
p.props.space_after = Some(0.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
let env = para::ParaEnv {
|
||||
|
||||
@@ -637,20 +637,9 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
|
||||
}
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
let obj = para.and_then(|p| p.objects.get(oi));
|
||||
let modern = doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
|
||||
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern);
|
||||
let rect = inline_rect(obj, cx, base, c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k));
|
||||
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
|
||||
match obj {
|
||||
// A turn its line keeps no room for overhangs the line; Word cuts it at the line's top
|
||||
// and bottom, not at its sides.
|
||||
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. }))
|
||||
if o.frame().is_some_and(|(w, h, f)| f.is_bare_turn() && f.spin_pad(w, h).1 > f.line_pad(w, h, modern).1 + 0.01) =>
|
||||
{
|
||||
let mut items = Vec::new();
|
||||
object_draws(o, rect, Spin::default(), alpha, opts, &mut items);
|
||||
let band = Rect::new(rect.x - 4000.0, ctx.top, rect.w + 8000.0, (ctx.bottom - ctx.top).max(0.0));
|
||||
out.push(Draw::Clip { rect: band, items });
|
||||
}
|
||||
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
|
||||
object_draws(o, rect, Spin::default(), alpha, opts, out)
|
||||
}
|
||||
|
||||
@@ -487,9 +487,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
|
||||
/// `obj_h` above the baseline and `obj_d` below it): inside the room kept for its effects.
|
||||
/// A rotated one's unrotated frame, centred in that room.
|
||||
///
|
||||
/// `line` is the room a horizontal rule is placed in, from `cx`; `modern`: Word 2013+ layout
|
||||
/// (see [`wordcraft_doc::para::Float::line_pad`]).
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32, modern: bool) -> Rect {
|
||||
/// `line` is the room a horizontal rule is placed in, from `cx`.
|
||||
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
|
||||
let frame = obj.and_then(InlineObject::frame);
|
||||
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
|
||||
// Lowered further than its height, it is drawn with its top on the baseline (Word).
|
||||
@@ -504,9 +503,9 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f
|
||||
return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h);
|
||||
}
|
||||
let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room };
|
||||
// The room is what the line kept for the turned frame: undo that.
|
||||
// The room is the rotated bounds of the frame: undo the rotation.
|
||||
let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0));
|
||||
let bw = w + 2.0 * float.line_pad(w, h, modern).0;
|
||||
let (bw, _) = float.spin().extent(w, h);
|
||||
let k = if bw > 0.0 { room.w / bw } else { 1.0 };
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh)
|
||||
|
||||
@@ -438,23 +438,13 @@ pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi:
|
||||
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
|
||||
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
|
||||
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
|
||||
/// them they are on the new page with it (#4262). The text may be in the same row's
|
||||
/// continuation on the far side of a wrapping object, not in the anchor's segment (Leaf #107).
|
||||
/// them they are on the new page with it (#4262).
|
||||
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
|
||||
let mut out = vec![None; p.objects.len()];
|
||||
for (li, l) in pl.lines.iter().enumerate() {
|
||||
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
|
||||
let after_break = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak));
|
||||
// A wrap can leave only the zero-width anchor in the row's left segment, with its
|
||||
// text on the right. That is still visible content after the break, not a cover box.
|
||||
let lead = after_break
|
||||
&& !clusters.iter().any(para::takes_room)
|
||||
&& !pl
|
||||
.lines
|
||||
.iter()
|
||||
.skip(li + 1)
|
||||
.take_while(|next| next.beside)
|
||||
.any(|next| pl.clusters.get(next.c0..next.c1).unwrap_or_default().iter().any(para::takes_room));
|
||||
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
|
||||
&& !clusters.iter().any(para::takes_room);
|
||||
for c in clusters {
|
||||
let para::ClKind::Object(oi) = c.kind else { continue };
|
||||
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
|
||||
@@ -761,8 +751,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
|
||||
}
|
||||
} else {
|
||||
let cx = x + line.cl_left(k).unwrap_or(0.0);
|
||||
let modern = ctx.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k), modern)
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, (c.obj_h, c.obj_d), pl.rule_room(line, k))
|
||||
};
|
||||
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
|
||||
@@ -281,17 +281,6 @@ impl<'a> Flow<'a> {
|
||||
if self.pl.rp.before_auto && self.block == 0 {
|
||||
before = 0.0;
|
||||
}
|
||||
// An empty paragraph after a document's leading table, placed floating on this page, has no
|
||||
// space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place).
|
||||
// Only that shape is observed: later pages and columns keep theirs.
|
||||
if self.block == 1
|
||||
&& pb.fresh()
|
||||
&& !pb.web
|
||||
&& pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0)))
|
||||
&& only_markers(self.p, 0..self.p.text.len())
|
||||
{
|
||||
before = 0.0;
|
||||
}
|
||||
before
|
||||
}
|
||||
|
||||
@@ -827,8 +816,7 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
|
||||
let mut f = Flow::new(ctx, pb, p, (block, body_top));
|
||||
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
|
||||
let placed_at = spot(pb);
|
||||
// A continuous section's top mid-page is not a page top, so the break still applies.
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.fresh() && !pb.web;
|
||||
let broke_before = f.pl.rp.page_break_before && !pb.at_top() && !pb.web;
|
||||
if broke_before {
|
||||
pb.new_page(block, body_top);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::chars::{
|
||||
use super::grid::east_asian;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, ParaEnv, RubyLayout, StyleRun, SymbolRun};
|
||||
use super::{Arc, Document, FaceRef, NumeralMode, ResolvedChar, RubyAlign, SOFT_HYPHEN};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
pub(super) struct Builder<'a> {
|
||||
pub(super) env: &'a ParaEnv<'a>,
|
||||
@@ -227,19 +226,6 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// `dir`: the bidi levels `text` is shaped by (see [`Dir`]).
|
||||
pub(super) fn shape_with(&mut self, text: &str, base: usize, rc: &Arc<ResolvedChar>, kind_override: Option<ClKind>, dir: Dir<'_>) {
|
||||
self.shape_around(text, base, rc, kind_override, dir, ShapeContext::default());
|
||||
}
|
||||
|
||||
/// `around`: the text shown beside `text`, which its first and last letters may join.
|
||||
pub(super) fn shape_around(
|
||||
&mut self,
|
||||
text: &str,
|
||||
base: usize,
|
||||
rc: &Arc<ResolvedChar>,
|
||||
kind_override: Option<ClKind>,
|
||||
dir: Dir<'_>,
|
||||
around: ShapeContext<'_>,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -388,15 +374,10 @@ impl<'a> Builder<'a> {
|
||||
let from = usize::from(rc.kerned());
|
||||
let to = if ligatures.is_some() { all.len() } else { 1 };
|
||||
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
|
||||
// Letters join across a change of font or direction inside the run too.
|
||||
let around = ShapeContext {
|
||||
before: if a == 0 { around.before } else { text.get(..a).unwrap_or("") },
|
||||
after: if b == text.len() { around.after } else { text.get(b..).unwrap_or("") },
|
||||
};
|
||||
let shaped = if use_levels {
|
||||
wordcraft_fonts::shape_run_in(&st.face, shape_text, features, map, key.rtl, around)
|
||||
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
|
||||
} else {
|
||||
wordcraft_fonts::shape_in(&st.face, shape_text, features, map, around)
|
||||
wordcraft_fonts::shape(&st.face, shape_text, features, map)
|
||||
};
|
||||
let k = st.size / st.face.upem.max(1.0) as f32;
|
||||
let hscale = rc.scale / 100.0;
|
||||
|
||||
@@ -130,17 +130,13 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria";
|
||||
/// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing.
|
||||
pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let roman = !doc.settings.roman_fonts.is_empty()
|
||||
&& font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f)));
|
||||
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !wordcraft_fonts::word::has_named_substitutes(font)
|
||||
&& if font.contains(';') {
|
||||
wordcraft_fonts::word::resolve(font, false, false).substituted
|
||||
} else {
|
||||
!db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
}
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
{
|
||||
MISSING_ROMAN_FONT
|
||||
} else {
|
||||
|
||||
@@ -92,8 +92,6 @@ struct Scan {
|
||||
j: usize,
|
||||
/// Tab cluster, stop, and x where the tab started.
|
||||
pending_tab: Option<(usize, TabStop, f32)>,
|
||||
/// The text after a pending decimal tab looked at so far for its '.'.
|
||||
decimal: Option<DecimalScan>,
|
||||
/// The width of the spaces so far on this line.
|
||||
space_w: f32,
|
||||
has_tab: bool,
|
||||
@@ -115,15 +113,17 @@ pub(crate) fn takes_room(c: &Cluster) -> bool {
|
||||
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
|
||||
}
|
||||
|
||||
/// How far the text after a decimal tab has been looked at for its first '.' (see
|
||||
/// [`Scan::width_to_decimal`]): the tab, the next cluster to look at, the width before it, and
|
||||
/// whether that cluster is the '.'.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct DecimalScan {
|
||||
tab: usize,
|
||||
next: usize,
|
||||
width: f32,
|
||||
found: bool,
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`.
|
||||
fn width_to_decimal(pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let mut w = 0.0;
|
||||
for k in tj + 1..=j {
|
||||
let cc = pl.clusters.get(k)?;
|
||||
if pl_text_at(cc) {
|
||||
return Some(w);
|
||||
}
|
||||
w += cc.adv;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The objects' exclusions mirrored across a line of `width`, for a right-to-left paragraph.
|
||||
@@ -279,7 +279,6 @@ impl<'e> Breaker<'e> {
|
||||
end: LineEnd::Para,
|
||||
j: self.i,
|
||||
pending_tab: None,
|
||||
decimal: None,
|
||||
space_w: 0.0,
|
||||
has_tab: false,
|
||||
skip: false,
|
||||
@@ -918,29 +917,6 @@ impl Scan {
|
||||
false
|
||||
}
|
||||
|
||||
/// The width up to the first '.' of the text after the tab at `tj`, up to cluster `j`. The
|
||||
/// text is shifted after each of its clusters, so the clusters already looked at are not
|
||||
/// walked again: a long run without a '.' after a decimal tab is looked at once, not once
|
||||
/// per cluster.
|
||||
fn width_to_decimal(&mut self, pl: &ParaLayout, tj: usize, j: usize) -> Option<f32> {
|
||||
let start = DecimalScan { tab: tj, next: tj + 1, width: 0.0, found: false };
|
||||
let d = self.decimal.get_or_insert(start);
|
||||
if d.tab != tj || d.next > j.saturating_add(1) {
|
||||
*d = start;
|
||||
}
|
||||
while !d.found && d.next <= j {
|
||||
crate::work::steps(1);
|
||||
let cc = pl.clusters.get(d.next)?;
|
||||
if pl_text_at(cc) {
|
||||
d.found = true;
|
||||
} else {
|
||||
d.width += cc.adv;
|
||||
d.next += 1;
|
||||
}
|
||||
}
|
||||
d.found.then_some(d.width)
|
||||
}
|
||||
|
||||
/// Text after a decimal/centre/right tab shifts as it grows.
|
||||
fn shift_pending_tab(&mut self, pl: &mut ParaLayout) {
|
||||
let Some((tj, stop, tx)) = self.pending_tab else { return };
|
||||
@@ -949,7 +925,7 @@ impl Scan {
|
||||
TabAlign::Right => stop.pos - tx - seg_w,
|
||||
TabAlign::Center => stop.pos - tx - seg_w / 2.0,
|
||||
// Width up to the first '.' in the segment.
|
||||
TabAlign::Decimal => stop.pos - tx - self.width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
TabAlign::Decimal => stop.pos - tx - width_to_decimal(pl, tj, self.j).unwrap_or(seg_w),
|
||||
_ => 0.0,
|
||||
};
|
||||
let shift = shift.max(0.0);
|
||||
|
||||
@@ -6,8 +6,6 @@ use super::justify::hyphenation_points;
|
||||
use super::lines::break_lines;
|
||||
use super::model::{ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, ParaEnv, RubyLayout, undrawable_bullet};
|
||||
use super::proof::{ProofRun, is_list_item, proof_issues};
|
||||
use wordcraft_fonts::ShapeContext;
|
||||
|
||||
use super::{
|
||||
Arc, COLUMN_BREAK, CharProps, Document, FIELD_SHADING, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, PTabAlign, PTabBase, ParaLayout,
|
||||
Paragraph, ResolvedChar, ResolvedPara, TabLeader, field_code_text, field_text_on, reference_link,
|
||||
@@ -35,8 +33,6 @@ struct Cx<'a> {
|
||||
math_props: wordcraft_doc::math::MathProps,
|
||||
/// The first character, if it is to be a drop cap.
|
||||
drop_first: Option<char>,
|
||||
/// The text on the page, when its letters can join across runs (see [`shown_text`]).
|
||||
shown: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a cluster for an object or a break goes in the paragraph: its text, style and first glyph.
|
||||
@@ -87,10 +83,6 @@ struct Shaped<'a> {
|
||||
bare: Vec<std::ops::Range<usize>>,
|
||||
/// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
|
||||
runs: Vec<ProofRun>,
|
||||
/// How far into the shown text ([`Cx::shown`]) the runs so far have come.
|
||||
shown_at: usize,
|
||||
/// Where in the shown text a letter's context may begin: past the drop cap, once one is drawn.
|
||||
shown_floor: usize,
|
||||
}
|
||||
|
||||
fn is_special(c: char) -> bool {
|
||||
@@ -194,8 +186,6 @@ impl<'a> Shaped<'a> {
|
||||
left: Vec::new(),
|
||||
bare: Vec::new(),
|
||||
runs: Vec::new(),
|
||||
shown_at: 0,
|
||||
shown_floor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,44 +236,24 @@ impl<'a> Shaped<'a> {
|
||||
|
||||
/// A run shown: its text, with the breaks and objects in it as clusters of their own.
|
||||
fn visible_run(&mut self, cx: &Cx, (rc, props): (&Arc<ResolvedChar>, &CharProps), range: &std::ops::Range<usize>, text: &str) {
|
||||
let shown_start = self.shown_at;
|
||||
let around = self.next_shown(cx, text.len());
|
||||
let mut seg = self.drop_cap_letter(cx, rc, text, range.start);
|
||||
if seg > 0 {
|
||||
// A drop cap is drawn apart from its word: nothing after it joins it, in this run or
|
||||
// in the runs after it (the letter can be formatted on its own).
|
||||
self.shown_floor = shown_start.saturating_add(seg);
|
||||
}
|
||||
// The first piece continues what came before the run. After a drop cap or a break nothing
|
||||
// joins: that letter is drawn apart, and a break is no letter. The same goes for the end.
|
||||
let mut before = if seg == 0 { around.before } else { "" };
|
||||
for (i, c) in text.char_indices() {
|
||||
if i < seg || !is_special(c) {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = text.get(seg..i) {
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: "" });
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
}
|
||||
before = "";
|
||||
seg = i + c.len_utf8();
|
||||
let style = self.b.style(rc, None, false);
|
||||
let slot = Slot { start: range.start + i, end: range.start + i + c.len_utf8(), style, g: self.b.glyphs.len() as u32 };
|
||||
self.special(cx, c, slot, (rc, props));
|
||||
}
|
||||
if let Some(s) = text.get(seg..) {
|
||||
self.b.shape_around(s, range.start + seg, rc, None, Dir::Para, ShapeContext { before, after: around.after });
|
||||
self.b.shape(s, range.start + seg, rc, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text shown on either side of the next `len` bytes shown, and moves past them. What
|
||||
/// comes before starts after a drop cap ([`Shaped::shown_floor`]).
|
||||
fn next_shown<'c>(&mut self, cx: &'c Cx, len: usize) -> ShapeContext<'c> {
|
||||
let Some(shown) = &cx.shown else { return ShapeContext::default() };
|
||||
let start = self.shown_at;
|
||||
self.shown_at = start.saturating_add(len);
|
||||
ShapeContext { before: shown.get(self.shown_floor.min(start)..start).unwrap_or(""), after: shown.get(self.shown_at..).unwrap_or("") }
|
||||
}
|
||||
|
||||
/// Drop cap: the first character, sized so its capital spans `lines` lines. Returns how much
|
||||
/// of the run's text it took.
|
||||
fn drop_cap_letter(&mut self, cx: &Cx, rc: &Arc<ResolvedChar>, text: &str, start: usize) -> usize {
|
||||
@@ -397,7 +367,7 @@ impl<'a> Shaped<'a> {
|
||||
// The line makes room for effects (shadows) around the picture too, and
|
||||
// for a rotated one's bounds, as Word does.
|
||||
let [el, et, er, eb] = float.effect_extent();
|
||||
let (px, py) = float.line_pad(w * s, h * s, env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT);
|
||||
let (px, py) = float.spin_pad(w * s, h * s);
|
||||
let tall = h * s + et + eb + 2.0 * py;
|
||||
// Lowered further than its height, its top stays on the baseline while the line
|
||||
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
|
||||
@@ -623,26 +593,6 @@ fn joined_cluster_starts(p: &Paragraph, clusters: &[Cluster]) -> Vec<usize> {
|
||||
joined
|
||||
}
|
||||
|
||||
/// The paragraph's text without the runs left out of the layout, if it has letters that join their
|
||||
/// neighbours: a letter joins the next one on the page, across a change of formatting and past
|
||||
/// text that isn't shown (#4093). `None` for every other paragraph, which don't need it.
|
||||
fn shown_text(cx: &Cx) -> Option<String> {
|
||||
let text = &cx.p.text;
|
||||
if text.is_ascii() || !text.chars().any(wordcraft_fonts::joins_cursively) {
|
||||
return None;
|
||||
}
|
||||
let mut shown = String::with_capacity(text.len());
|
||||
for (range, props) in cx.p.run_ranges() {
|
||||
let rc = cx.res.char(props);
|
||||
if !left_out(cx.env.doc, &rc, cx.env.show_hidden, cx.env.hide_deleted)
|
||||
&& let Some(run) = text.get(range)
|
||||
{
|
||||
shown.push_str(run);
|
||||
}
|
||||
}
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
/// Lay out one paragraph.
|
||||
pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let doc = env.doc;
|
||||
@@ -659,9 +609,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
shown: Vec::new(),
|
||||
substituted: Vec::new(),
|
||||
};
|
||||
let mut cx =
|
||||
Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp), shown: None };
|
||||
cx.shown = shown_text(&cx);
|
||||
let cx = Cx { p, env, rp: &rp, res, math_props: doc.settings.math.clone().unwrap_or_default(), drop_first: drop_cap_char(p, &rp) };
|
||||
let mut sh = Shaped::new(b, env.eq_number);
|
||||
let mark_rc = cx.res.char(&p.mark);
|
||||
let mark_style = sh.b.style(&mark_rc, None, false);
|
||||
@@ -837,36 +785,27 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec
|
||||
}
|
||||
|
||||
// Word breaks after a hyphen even when a digit follows ("2016-" / "2022"), where UAX #14
|
||||
// keeps a hyphen and a number together (LB25), and when a slash and a letter follow
|
||||
// ("Dataprotection-" / "/Secrecyviolation", #3408), where LB13 forbids a break before the
|
||||
// slash: add those positions back. Word keeps "fitness-/1589" whole, so a slash before a digit
|
||||
// gains no break. Only U+002D is added here; a non-breaking hyphen (U+2011) and a minus sign
|
||||
// (U+2212) stay unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
|
||||
// keeps a hyphen and a number together (LB25): add those positions back. Only U+002D before
|
||||
// ASCII digits is added here; a non-breaking hyphen (U+2011) and a minus sign (U+2212) stay
|
||||
// unbreakable, and the other dashes keep their ordinary UAX #14 breaks.
|
||||
let hyphen_breaks: Vec<usize> = text
|
||||
.match_indices('-')
|
||||
.filter_map(|(pos, _)| {
|
||||
let i = pos + 1;
|
||||
let mut next = text.get(i..).unwrap_or_default().chars();
|
||||
match next.next() {
|
||||
Some(n) if n.is_ascii_digit() => Some(i),
|
||||
Some('/') => next.next().is_some_and(char::is_alphabetic).then_some(i),
|
||||
_ => None,
|
||||
}
|
||||
text.get(i..).and_then(|t| t.chars().next()).is_some_and(|n| n.is_ascii_digit()).then_some(i)
|
||||
})
|
||||
.collect();
|
||||
found.extend(hyphen_breaks);
|
||||
// The breaks added above come after UAX #14's: put them back in order.
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
if !hyphen_breaks.is_empty() {
|
||||
found.extend(hyphen_breaks);
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
// Myanmar has no spaces between words: it wraps between syllables.
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x1000..=0x109F | 0xA9E0..=0xA9FF | 0xAA60..=0xAA7F)) {
|
||||
found.extend(wordcraft_doc::cluster::myanmar_breaks(text));
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
}
|
||||
// UAX #14 breaks between a full stop and an opening parenthesis; Word keeps
|
||||
// "2023.(Example" whole and wraps it at the space before.
|
||||
found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('('))));
|
||||
// Asian typography: kinsoku and breaking Latin words anywhere.
|
||||
crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap);
|
||||
found
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use super::cache::{LayoutCache, Totals, env_hash, hash_of};
|
||||
use super::ctx::{Ctx, Pass, with_pct_base};
|
||||
use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects};
|
||||
use super::floats::{EarlyFloats, WrapArea, early_covered};
|
||||
use super::flow::{OutlinePass, place_outline_para, place_para};
|
||||
use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side};
|
||||
use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes};
|
||||
@@ -10,10 +10,9 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom};
|
||||
use super::placed::Placed;
|
||||
use super::table_flow::place_table;
|
||||
use super::{
|
||||
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart,
|
||||
StoryRef, TextDirection, ViewMode, balloons, para, vertical,
|
||||
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection,
|
||||
ViewMode, balloons, para, vertical,
|
||||
};
|
||||
use wordcraft_doc::legacy_form::LegacyForm;
|
||||
|
||||
/// How the view lays the document out: `web` for the continuous views, `reflow` for those and
|
||||
/// the reading view, which both ignore the sections' page geometry.
|
||||
@@ -128,35 +127,18 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect
|
||||
end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty())
|
||||
});
|
||||
if !opts.show_hidden {
|
||||
quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| {
|
||||
b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i)
|
||||
}));
|
||||
quiet.extend(
|
||||
doc.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)),
|
||||
);
|
||||
}
|
||||
quiet.sort_unstable();
|
||||
quiet.dedup();
|
||||
(stacked, quiet)
|
||||
}
|
||||
|
||||
/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other
|
||||
/// than its result, is visible content with no plain text, so its line keeps its room even when
|
||||
/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one
|
||||
/// in hidden or deleted text).
|
||||
fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool {
|
||||
let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body));
|
||||
// As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry.
|
||||
let shown_field = |o: &InlineObject| match o {
|
||||
InlineObject::Field { code, .. } => {
|
||||
*code != opts.field_codes
|
||||
|| o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty()))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
p.objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o)))
|
||||
}
|
||||
|
||||
/// The page the continuous views lay out on: the first section's, as wide as the window (and as
|
||||
/// high in the reading view), with small margins and one column.
|
||||
fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
|
||||
|
||||
@@ -1201,16 +1201,14 @@ fn align_in_part(
|
||||
}
|
||||
let limit = cut - row.bottom.clamp(0.0, cut);
|
||||
let mut kept = f32::MIN;
|
||||
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
|
||||
// No further than the cell's content: its last paragraph may have dropped its space after.
|
||||
let end = end.unwrap_or(f32::MAX);
|
||||
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
|
||||
for (it, &split) in items.iter().zip(splits) {
|
||||
let bottom = match (it.turned_bounds(), it) {
|
||||
(Some(r), _) => Some(r.bottom()),
|
||||
// A paragraph kept whole ends with its space after.
|
||||
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
|
||||
.filter(|&b| b <= limit + 0.01)
|
||||
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
|
||||
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
|
||||
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
|
||||
Some(rect.bottom())
|
||||
}
|
||||
|
||||
@@ -94,62 +94,7 @@ fn row_keeps_next(row: &table::RowLayout) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The cut that leaves every row of a nested table marked "can't split" whole: just below the
|
||||
/// top of the highest such row the cut at `room` runs through, moved up again while it runs
|
||||
/// through another one (Word moves such a row on with the rest of the outer row, observed in the
|
||||
/// docxide-pdf `nested-first-row-*-cant1` cases). Like the split itself, a row "runs through"
|
||||
/// the cut when it ends below the room left for text there, above the row's bottom band; the cut
|
||||
/// goes that band below the row's top, so the text and rows ending at its top stay.
|
||||
/// shortcut: the moved row's side borders and shading keep that band's height on the page, and
|
||||
/// on the next page its cell area and shading start that band less its top margin below its
|
||||
/// first line (clicks there miss the cell). Only an explicit bottom cell margin or a thick outer
|
||||
/// border shows it, never Word's defaults; upgrade with a separate drawing cut in split_row that
|
||||
/// moves a nested cell starting between the limit and the cut whole from its top.
|
||||
fn nested_keep_cut(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> f32 {
|
||||
let own = row.items.iter().filter_map(|it| if let Placed::Cell { table, .. } = it { Some(table.0.len()) } else { None }).min();
|
||||
// Each nested row's top and bottom when it can't split. A vertically merged cell runs past
|
||||
// its own row, so a row ends where its shortest cell does.
|
||||
let mut bands: std::collections::BTreeMap<(StoryRef, &[u32], usize), Option<(f32, f32)>> = std::collections::BTreeMap::new();
|
||||
// The split runs a cell aligned to the middle or bottom from its top: so do its bands.
|
||||
let mut shift = vec![0.0f32; row.items.len()];
|
||||
for al in row.cells.iter().filter_map(|c| c.align.as_ref()) {
|
||||
shift.get_mut(al.content.clone()).into_iter().flatten().for_each(|d| *d = al.dy);
|
||||
}
|
||||
for (it, &dy) in row.items.iter().zip(&shift) {
|
||||
let Placed::Cell { rect, table, row: r, story, .. } = it else { continue };
|
||||
if Some(table.0.len()) <= own {
|
||||
continue;
|
||||
}
|
||||
let rect = Rect { y: rect.y - dy, ..*rect };
|
||||
let band = bands
|
||||
.entry((*story, table.0.as_slice(), *r))
|
||||
.or_insert_with(|| doc.table(*story, table).and_then(|t| t.rows.get(*r)).filter(|r| r.props.cant_split).map(|_| (rect.y, rect.bottom())));
|
||||
if let Some((top, bottom)) = band {
|
||||
*top = top.min(rect.y);
|
||||
*bottom = bottom.min(rect.bottom());
|
||||
}
|
||||
}
|
||||
let below = row.bottom.clamp(0.0, room.max(0.0));
|
||||
let mut bands: Vec<(f32, f32)> = bands.into_values().flatten().collect();
|
||||
// From the lowest top up: once the cut moves up to a row, the rows starting below it are
|
||||
// wholly under it, and a row the old cut ran through still runs through the new one.
|
||||
bands.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut cut = room;
|
||||
for (top, bottom) in bands {
|
||||
let limit = cut - below;
|
||||
if top < limit && bottom > limit + 0.01 {
|
||||
cut = top + below;
|
||||
}
|
||||
}
|
||||
cut
|
||||
}
|
||||
|
||||
/// [`table::split_row_keeping_cells`] that doesn't split a nested "can't split" row either.
|
||||
fn split_keeping_nested(doc: &wordcraft_doc::Document, row: &table::RowLayout, room: f32) -> Option<(table::RowLayout, table::RowLayout)> {
|
||||
table::split_row_keeping_cells(row, nested_keep_cut(doc, row, room))
|
||||
}
|
||||
|
||||
fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
if laid.height <= room + 0.01 {
|
||||
return true;
|
||||
}
|
||||
@@ -159,7 +104,7 @@ fn row_can_start(doc: &wordcraft_doc::Document, row: &wordcraft_doc::Row, laid:
|
||||
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
|
||||
return false;
|
||||
}
|
||||
split_keeping_nested(doc, laid, room).is_some()
|
||||
table::split_row_keeping_cells(laid, room).is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
@@ -242,7 +187,8 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
{
|
||||
let room = pb.bottom - pb.y - tl.rows.iter().take(first).map(|r| r.height).sum::<f32>();
|
||||
let splittable = !t.rows.get(first).is_some_and(|r| r.props.cant_split);
|
||||
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && split_keeping_nested(ctx.doc, row, room).is_some())) {
|
||||
if row.height + row.edge > room + 0.01 && (short_of_min(first, room) || !(splittable && table::split_row_keeping_cells(row, room).is_some()))
|
||||
{
|
||||
pb.advance(block, body_top);
|
||||
}
|
||||
}
|
||||
@@ -277,7 +223,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
}
|
||||
let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false };
|
||||
let page_height = pb.bottom - body_top - repeated_height;
|
||||
height <= page_height + 0.01 && !row_can_start(ctx.doc, next, laid, pb.bottom - pb.y - height, page_height)
|
||||
height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height)
|
||||
});
|
||||
// A row another row follows needs room for the border below it too (measured in Word).
|
||||
if pb.y + cur.height + cur.edge <= pb.bottom + 0.01 && !defer_kept {
|
||||
@@ -292,9 +238,8 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
|
||||
let moves_on = first_part && short_of_min(ri, room);
|
||||
let split = match (splittable && !moves_on && !defer_kept, first_part) {
|
||||
(false, _) => None,
|
||||
(true, true) => split_keeping_nested(ctx.doc, cur, room),
|
||||
// On a fresh page, a nested row taller than the room splits rather than overflowing.
|
||||
(true, false) => table::split_row(cur, nested_keep_cut(ctx.doc, cur, room)).or_else(|| table::split_row(cur, room)),
|
||||
(true, true) => table::split_row_keeping_cells(cur, room),
|
||||
(true, false) => table::split_row(cur, room),
|
||||
};
|
||||
match split {
|
||||
Some((a, b)) => {
|
||||
@@ -349,13 +294,7 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa
|
||||
let x = float_table_x(pb, w, f, legacy, ctx.mirror);
|
||||
let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) };
|
||||
obstacles.extend_from_slice(&pb.float_tables);
|
||||
let r = clear_of_float_tables(
|
||||
Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h),
|
||||
f,
|
||||
ctx.doc.settings.compat_mode >= 15,
|
||||
h_area.0,
|
||||
&obstacles,
|
||||
);
|
||||
let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles);
|
||||
// Like Word, a table that doesn't fit below the text before it moves to the next page with
|
||||
// its anchor: one placed relative to the paragraph when it would run off the page, one
|
||||
// placed on the page or margin when it would also cover the text already on the page.
|
||||
|
||||
+4
-485
@@ -9,12 +9,10 @@ mod bidi;
|
||||
mod budget;
|
||||
mod cell_floats;
|
||||
mod complex;
|
||||
mod float_top;
|
||||
mod grid;
|
||||
mod ligature;
|
||||
pub(crate) mod linear;
|
||||
mod nbsp;
|
||||
mod nested_cant_split;
|
||||
mod numeral;
|
||||
mod pages;
|
||||
mod styleref;
|
||||
@@ -298,82 +296,6 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Baseline of the paragraph after a one-row table, less that of the first cell's last
|
||||
/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell
|
||||
/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is
|
||||
/// the last paragraph's text.
|
||||
fn cell_end_gap(style: &str, contextual: bool) -> f32 {
|
||||
cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe")
|
||||
}
|
||||
|
||||
fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "Other".into(),
|
||||
name: "Other".into(),
|
||||
based_on: Some("Normal".into()),
|
||||
..Default::default()
|
||||
});
|
||||
let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() };
|
||||
let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() };
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() };
|
||||
let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0);
|
||||
table.props.style = None;
|
||||
table.props.cell_margins = Some([0.0; 4]);
|
||||
let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() };
|
||||
table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)];
|
||||
if let Some(cell) = table.rows[0].cells.get_mut(1) {
|
||||
cell.blocks = vec![para("Delta probe", styled(next_cell))];
|
||||
}
|
||||
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))];
|
||||
let l = lay(&d);
|
||||
let baseline = |at: &[u32]| {
|
||||
l.pages[0].items.iter().find_map(|i| match i {
|
||||
Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") };
|
||||
after - cell
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() {
|
||||
// The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a
|
||||
// Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not
|
||||
// after one in another style (`cell-end-other-style`) or without contextual spacing.
|
||||
let kept = cell_end_gap("Normal", false);
|
||||
assert!((cell_end_gap("Other", true) - kept).abs() < 0.01);
|
||||
let dropped = cell_end_gap("Normal", true);
|
||||
assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}");
|
||||
// An undefined style is formatted as Normal, and drops it too.
|
||||
assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01);
|
||||
// The mark decides, not a neighbour: another style in the next cell or after the table
|
||||
// leaves the drop as it is.
|
||||
for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] {
|
||||
let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe");
|
||||
assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}");
|
||||
}
|
||||
// And the reverse: another style keeps its space beside Normal neighbours.
|
||||
let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe");
|
||||
assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}");
|
||||
// An empty last paragraph drops it too (`cell-end-empty-context`).
|
||||
let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, "");
|
||||
assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}");
|
||||
// Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the
|
||||
// 12 pt stays.
|
||||
let mut legacy = Document::new();
|
||||
legacy.settings.compat_mode = 14;
|
||||
legacy.settings.allow_space_of_same_style_in_table = true;
|
||||
let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe");
|
||||
assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}");
|
||||
}
|
||||
|
||||
fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let mut d = Document::new();
|
||||
@@ -3927,40 +3849,6 @@ fn extreme_page_and_line_number_starts_do_not_overflow() {
|
||||
assert!(l.pages.len() >= 3, "{} pages", l.pages.len());
|
||||
}
|
||||
|
||||
/// Line numbers take the document's character formatting through the "Line Number" style, as in
|
||||
/// Word (Aptos 12 pt black in a document whose defaults are Aptos 12 pt), not a fixed small grey.
|
||||
#[test]
|
||||
fn line_numbers_follow_the_line_number_style() {
|
||||
let number_runs = |d: &Document| {
|
||||
let l = lay(d);
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
d.last_section.line_numbers = Some(Default::default());
|
||||
Arc::make_mut(&mut d.styles).default_chr.size = Some(12.0);
|
||||
let plain = number_runs(&d);
|
||||
assert_eq!(plain.len(), 3);
|
||||
assert!(plain.iter().all(|(size, color)| *size == 12.0 && *color == wordcraft_doc::TextColor::Auto), "{plain:?}");
|
||||
|
||||
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
|
||||
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
|
||||
id: "LineNumber".into(),
|
||||
name: "line number".into(),
|
||||
kind: wordcraft_doc::StyleKind::Character,
|
||||
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let styled = number_runs(&d);
|
||||
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_borders_text_boxes() {
|
||||
let mut d = Document::from_text("one\ntwo\nthree");
|
||||
@@ -5705,7 +5593,7 @@ fn inline_picture_keeps_room_for_its_effects() {
|
||||
assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents");
|
||||
// Drawn inside that room.
|
||||
let obj = picture(100.0, 50.0, shadow);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -5732,67 +5620,22 @@ fn an_inline_picture_moves_with_its_runs_position() {
|
||||
let (c, line, _) = line_of(-30.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (0.0, 30.0));
|
||||
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
assert_eq!((r.y, r.h), (100.0, 30.0));
|
||||
// Lowered further than that: the line reaches as far down, the picture's top staying on the
|
||||
// baseline (Word).
|
||||
let (c, line, _) = line_of(-500.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
|
||||
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
assert_eq!((r.y, r.h), (100.0, 30.0));
|
||||
// Raised: it stands clear of the baseline, as tall as before.
|
||||
let (c, _, _) = line_of(6.0);
|
||||
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv, true);
|
||||
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
|
||||
assert_eq!((r.y, r.h), (64.0, 30.0));
|
||||
}
|
||||
|
||||
/// A turned inline picture whose file kept no room for the turn: Word keeps its line to the frame
|
||||
/// (Word 2007 layout) or to the frame with width and height exchanged near a quarter turn (Word
|
||||
/// 2013+), and the picture overhangs it. One with room recorded takes its rotated bounds.
|
||||
#[test]
|
||||
fn bare_turned_inline_picture_keeps_its_frame() {
|
||||
let room = |rot: f32, bare_turn: bool, compat: u32| {
|
||||
let mut d = Document::from_text("");
|
||||
d.settings.compat_mode = compat;
|
||||
let float = wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() };
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.insert_object(0, picture(120.0, 60.0, float), &Default::default()).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
|
||||
// Drawn unscaled, centred in that room.
|
||||
let obj = picture(120.0, 60.0, wordcraft_doc::para::Float { rot, bare_turn, ..Default::default() });
|
||||
let r = display::inline_rect(Some(&obj), 0.0, 200.0, c.adv, (c.obj_h, c.obj_d), c.adv, compat >= 15);
|
||||
assert_eq!((r.w, r.h), (120.0, 60.0), "{rot}° bare {bare_turn} compat {compat}");
|
||||
assert_eq!((r.x + r.w / 2.0, r.y + r.h / 2.0), (c.adv / 2.0, 200.0 - c.obj_h / 2.0));
|
||||
// Only a bare turn that overhangs its line is cut, at the line's top and bottom.
|
||||
let overhangs =
|
||||
bare_turn && (c.obj_h as f64) < 120.0 * (rot as f64).to_radians().sin().abs() + 60.0 * (rot as f64).to_radians().cos().abs() - 0.1;
|
||||
let clips: Vec<Rect> = display::page_display(&d, &l.pages[0], &Default::default())
|
||||
.iter()
|
||||
.filter_map(|i| if let display::Draw::Clip { rect, .. } = i { Some(*rect) } else { None })
|
||||
.collect();
|
||||
assert_eq!(clips.len(), usize::from(overhangs), "{rot}° bare {bare_turn} compat {compat}: {clips:?}");
|
||||
if let Some(band) = clips.first() {
|
||||
assert!(band.h >= c.obj_h && band.w > 4000.0, "{band:?}");
|
||||
}
|
||||
(c.adv.round(), c.obj_h.round())
|
||||
};
|
||||
assert_eq!(room(90.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(45.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(180.0, true, 12), (120.0, 60.0));
|
||||
assert_eq!(room(90.0, true, 15), (60.0, 120.0));
|
||||
assert_eq!(room(45.0, true, 15), (60.0, 120.0));
|
||||
assert_eq!(room(135.0, true, 15), (120.0, 60.0));
|
||||
assert_eq!(room(270.0, true, 15), (60.0, 120.0));
|
||||
// Room recorded (Word's own files, WordCraft's turns): the rotated bounds, in any layout.
|
||||
assert_eq!(room(90.0, false, 12), (60.0, 120.0));
|
||||
assert_eq!(room(45.0, false, 15), (127.0, 127.0));
|
||||
}
|
||||
|
||||
/// Tops of the body text lines drawn on page one, in order.
|
||||
fn line_tops(l: &DocLayout) -> Vec<f32> {
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, story: StoryRef::Body, .. } = i { Some(*y) } else { None }).collect()
|
||||
@@ -7125,48 +6968,6 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() {
|
||||
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
|
||||
}
|
||||
|
||||
/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also
|
||||
/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in
|
||||
/// the body and in a table cell.
|
||||
#[test]
|
||||
fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() {
|
||||
use wordcraft_doc::para::Anchor;
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
let narrow_x = |mode: u32, in_cell: bool| {
|
||||
let mut d = Document::from_text("Body.");
|
||||
d.settings.compat_mode = mode;
|
||||
let table = |w: f32, text: &str, dist: [f32; 4]| {
|
||||
let mut t = Table::new(1, 1, w);
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() });
|
||||
t
|
||||
};
|
||||
let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])];
|
||||
if in_cell {
|
||||
let mut outer = Table::new(1, 1, 468.0);
|
||||
outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect();
|
||||
outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default())));
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap();
|
||||
} else {
|
||||
for (i, t) in floats.into_iter().enumerate() {
|
||||
d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap();
|
||||
}
|
||||
}
|
||||
let l = lay(&d);
|
||||
let xs: Vec<(f32, f32)> =
|
||||
l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect();
|
||||
let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") };
|
||||
assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}");
|
||||
nx - wx
|
||||
};
|
||||
for in_cell in [false, true] {
|
||||
let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell));
|
||||
assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one");
|
||||
assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one");
|
||||
}
|
||||
}
|
||||
|
||||
/// A margin-anchored floating table is placed from the anchor page's margin even when a body
|
||||
/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every
|
||||
/// table row is drawn exactly once there.
|
||||
@@ -9090,34 +8891,6 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() {
|
||||
assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_digits_advance_at_the_half_point_size() {
|
||||
use wordcraft_doc::props::VertAlign;
|
||||
// Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its
|
||||
// digits advance by 7pt digit widths (Word for Mac, measured from its PDF export).
|
||||
let width = |size: f32, vert_align: VertAlign| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
let props =
|
||||
wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() };
|
||||
p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() });
|
||||
p.text.push_str("1234567890");
|
||||
p.mark = props;
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
let l = lay(&d);
|
||||
// From the first digit's start to the last's: nine digit advances.
|
||||
let xs = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None })
|
||||
.unwrap();
|
||||
xs.last().unwrap() - xs.first().unwrap()
|
||||
};
|
||||
let seven = width(7.0, VertAlign::Baseline);
|
||||
assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript));
|
||||
assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() {
|
||||
use wordcraft_doc::para::RubyAlign;
|
||||
@@ -10465,20 +10238,6 @@ fn empty_page_section_keeps_line() {
|
||||
/// side by side). A section break on a paragraph with text keeps its line, and so does any break
|
||||
/// before a section on a new page. The SARP form's fields drifted a line lower at each of its
|
||||
/// many continuous breaks.
|
||||
/// A continuous section starting mid-page leaves its first paragraph's page break before in
|
||||
/// force, measured in Word for Mac: "Cover", an empty mark closing a continuous section, then
|
||||
/// "Heading" with page break before puts "Heading" on page 2.
|
||||
#[test]
|
||||
fn a_page_break_before_after_a_continuous_mark_starts_a_page() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let mut d = Document::from_text("Cover\n\nHeading");
|
||||
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section =
|
||||
Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() }));
|
||||
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.page_break_before = Some(true);
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
assert_eq!(lay(&d).caret(&Pos::body(2, 0)).unwrap().page, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
@@ -10512,79 +10271,6 @@ fn an_empty_continuous_section_mark_takes_no_room() {
|
||||
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
|
||||
}
|
||||
|
||||
/// A hidden mark closing a section before a continuous one, holding only an in-line picture:
|
||||
/// the picture keeps its line, so the next section starts below it, not over it (#5288). A
|
||||
/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden
|
||||
/// text.
|
||||
#[test]
|
||||
fn a_hidden_mark_with_an_inline_picture_keeps_its_room() {
|
||||
use wordcraft_doc::para::{Float, ShapeKind};
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
// Where Pb starts, and the bottom of the closing mark's picture (none: no picture).
|
||||
let tops = |float: Option<Float>, run: CharProps| {
|
||||
let mut d = Document::from_text("Pa\n\nPb");
|
||||
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
closer.mark.hidden = Some(true);
|
||||
closer.section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
if let Some(float) = float {
|
||||
let shape = InlineObject::Shape {
|
||||
kind: ShapeKind::Rectangle,
|
||||
w: 144.0,
|
||||
h: 72.0,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float,
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
d.insert_object(&Pos::body(1, 0), shape, &run).unwrap();
|
||||
}
|
||||
let l = lay(&d);
|
||||
let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom());
|
||||
(l.caret(&Pos::body(2, 0)).unwrap(), bottom)
|
||||
};
|
||||
let wrapped = |wrap| Some(Float { wrap, ..Default::default() });
|
||||
let (empty, _) = tops(None, CharProps::default());
|
||||
let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default());
|
||||
let bottom = bottom.unwrap();
|
||||
assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}");
|
||||
assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}");
|
||||
assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}");
|
||||
let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default());
|
||||
assert!(drawn.is_some(), "the floating picture is drawn");
|
||||
assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}");
|
||||
let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default());
|
||||
assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}");
|
||||
let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() });
|
||||
assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}");
|
||||
}
|
||||
|
||||
/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result):
|
||||
/// the box is drawn with a line's room, so the next section starts below it (#5288).
|
||||
#[test]
|
||||
fn a_hidden_mark_with_a_check_box_keeps_its_room() {
|
||||
use wordcraft_doc::section::{SectionProps, SectionStart};
|
||||
let top = |boxed: bool| {
|
||||
let mut d = Document::from_text("Pa\n\nPb");
|
||||
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
closer.mark.hidden = Some(true);
|
||||
closer.section = Some(Box::new(SectionProps::default()));
|
||||
d.last_section.start = SectionStart::Continuous;
|
||||
if boxed {
|
||||
let form = r#"<w:ffData><w:name w:val="Box"/><w:checkBox><w:sizeAuto/></w:checkBox></w:ffData>"#.to_string();
|
||||
let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form };
|
||||
d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap();
|
||||
}
|
||||
lay(&d).caret(&Pos::body(2, 0)).unwrap()
|
||||
};
|
||||
let (empty, boxed) = (top(false), top(true));
|
||||
assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}");
|
||||
}
|
||||
|
||||
/// Empty marks closing continuous sections one after another each keep their line while it
|
||||
/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text
|
||||
/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End":
|
||||
@@ -12262,88 +11948,6 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() {
|
||||
assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}");
|
||||
}
|
||||
|
||||
/// The line tops on the first page of a row split across pages, by cell: a label aligned with
|
||||
/// `valign`, 30 lines that split, and a 160 pt spacer above `note`.
|
||||
fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec<f32>; 3] {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
let plain = split_note_plain();
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let mut d = Document::new();
|
||||
(d.last_section.page_w, d.last_section.page_h) = (400.0, 360.0);
|
||||
d.last_section.margin_top = 36.0;
|
||||
d.last_section.margin_bottom = 36.0;
|
||||
let mut t = Table::new(1, 3, 328.0);
|
||||
t.props.style = None;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].cells[0].props.valign = Some(valign);
|
||||
t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())];
|
||||
t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect();
|
||||
let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() };
|
||||
t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())];
|
||||
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))];
|
||||
let l = lay(&d);
|
||||
let tops = |cell: u32| -> Vec<f32> {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let cells = [tops(0), tops(1), tops(2)];
|
||||
assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}");
|
||||
assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}");
|
||||
cells
|
||||
}
|
||||
|
||||
/// Single 12 pt lines with no space around them.
|
||||
fn split_note_plain() -> ParaProps {
|
||||
ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
// The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would
|
||||
// otherwise reach below the split lines: a bottom label lines up with those, a middle one is
|
||||
// centred between their top and bottom.
|
||||
let plain = split_note_plain();
|
||||
let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() };
|
||||
let automatic = ParaProps { space_after_auto: Some(true), ..plain };
|
||||
for note in [contextual, automatic] {
|
||||
let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, ¬e);
|
||||
assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}");
|
||||
let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, ¬e);
|
||||
let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") };
|
||||
let want = first + (last - first) / 2.0;
|
||||
assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() {
|
||||
use wordcraft_doc::props::VAlign;
|
||||
// A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom
|
||||
// label's line ends 14 pt below the note's.
|
||||
let note = ParaProps { space_after: Some(14.0), ..split_note_plain() };
|
||||
let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, ¬e);
|
||||
let (Some(&label), Some(¬e_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") };
|
||||
assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}");
|
||||
assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}");
|
||||
}
|
||||
|
||||
fn row_keep_pages(d: &Document) -> Vec<usize> {
|
||||
let l = lay(d);
|
||||
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
|
||||
@@ -13887,91 +13491,6 @@ fn a_non_breaking_hyphen_and_a_minus_sign_stay_unbreakable() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3408: Word breaks a compound right after a hyphen that a slash follows ("Dataprotection-" /
|
||||
/// "/Secrecyviolation"), where UAX #14 forbids a break before the slash. The first width that
|
||||
/// wraps must break after the hyphen, not move the compound whole, and the slash stays with its
|
||||
/// word. Table auto-fit reads the same break, so such a column no longer widens to the compound.
|
||||
/// Word keeps "fitness-/1589" whole: a slash before a digit gains no break.
|
||||
#[test]
|
||||
fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() {
|
||||
let digits = "Deed fitness-/1589";
|
||||
let mut wrapped = false;
|
||||
for m in 100..=540 {
|
||||
let mut d = Document::from_text(digits);
|
||||
d.last_section.margin_right = m as f32;
|
||||
let l = lay(&d);
|
||||
let lines: Vec<&str> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| if let Placed::Lines { para, l0, l1, .. } = it { Some(¶.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();
|
||||
|
||||
@@ -639,149 +639,3 @@ fn list_label_after_leading_page_break_uses_its_text_lines_alignment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph ids of each letter of paragraph 0 as laid out (marks of runs left out have none).
|
||||
fn letters(l: &DocLayout) -> Vec<Vec<u32>> {
|
||||
let (pl, _) = first_line(l, 0);
|
||||
pl.clusters.iter().filter(|c| c.g1 > c.g0).map(|c| pl.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect()).collect()
|
||||
}
|
||||
|
||||
/// `text` as one right-to-left paragraph with `format` applied to each `(start, end)` byte range.
|
||||
fn formatted(text: &str, splits: &[(usize, usize)], format: &dyn Fn(&mut wordcraft_doc::props::CharProps)) -> Document {
|
||||
let mut d = doc_of(&[(text, true)]);
|
||||
for &(start, end) in splits {
|
||||
d.format_range(&Pos::body(0, start), &Pos::body(0, end), format).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
const BEH: usize = 'ب'.len_utf8();
|
||||
|
||||
fn red(c: &mut wordcraft_doc::props::CharProps) {
|
||||
c.color = Some(wordcraft_doc::props::TextColor::Rgb(wordcraft_doc::props::Rgb(0xC0, 0, 0)));
|
||||
}
|
||||
|
||||
/// The reporter's symptom: after a change of formatting inside a word, the letters on either side
|
||||
/// took the forms of the end and the start of a word, which reads as a space in it (#4093).
|
||||
#[test]
|
||||
fn letters_join_across_a_change_of_formatting() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بببب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
assert_eq!(whole.len(), 4, "one cluster per letter: {whole:?}");
|
||||
assert!(whole.first() != whole.get(1) && whole.get(1) != whole.last(), "initial, medial and final forms: {whole:?}");
|
||||
// A letter of its own at each place, then the two middle letters, then every other letter.
|
||||
let pieces: [&[(usize, usize)]; 6] =
|
||||
[&[(0, BEH)], &[(BEH, 2 * BEH)], &[(2 * BEH, 3 * BEH)], &[(3 * BEH, 4 * BEH)], &[(BEH, 3 * BEH)], &[(0, BEH), (2 * BEH, 3 * BEH)]];
|
||||
for runs in pieces {
|
||||
let d = formatted(word, runs, &red);
|
||||
assert!(d.para_at(&Pos::body(0, 0)).unwrap().runs.len() > 1, "{runs:?} split the run");
|
||||
assert_eq!(letters(&lay(&d)), whole, "split at {runs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A letter joins what is on the page: with a tracked deletion shown, the deleted letter; with it
|
||||
/// left out, the letter past it. The same goes for hidden text.
|
||||
#[test]
|
||||
fn letters_join_past_text_left_out_of_the_layout() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let (three, two) = (letters(&lay(&doc_of(&[("ببب", true)]))), letters(&lay(&doc_of(&[("بب", true)]))));
|
||||
assert_eq!((three.len(), two.len()), (3, 2));
|
||||
let middle = [(BEH, 2 * BEH)];
|
||||
|
||||
let deleted = formatted("ببب", &middle, &|c| c.del = Some(0));
|
||||
assert_eq!(letters(&lay(&deleted)), three, "deletion shown: it joins both neighbours");
|
||||
let mut c = LayoutCache::new();
|
||||
let final_text = layout(&deleted, &mut c, &LayoutOptions { hide_deleted: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&final_text), two, "deletion left out: its neighbours join each other");
|
||||
|
||||
let hidden = formatted("ببب", &middle, &|c| c.hidden = Some(true));
|
||||
assert_eq!(letters(&lay(&hidden)), two, "hidden text left out: its neighbours join each other");
|
||||
let mut c = LayoutCache::new();
|
||||
let shown = layout(&hidden, &mut c, &LayoutOptions { show_hidden: true, ..LayoutOptions::default() });
|
||||
assert_eq!(letters(&shown), three, "hidden text shown: it joins both neighbours");
|
||||
}
|
||||
|
||||
/// A mark in a run of its own (a coloured vowel sign) doesn't stop the letters around it joining.
|
||||
#[test]
|
||||
fn letters_join_past_a_mark_in_a_run_of_its_own() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let word = "بَب";
|
||||
let whole = letters(&lay(&doc_of(&[(word, true)])));
|
||||
let split = letters(&lay(&formatted(word, &[(BEH, BEH + 'َ'.len_utf8())], &red)));
|
||||
assert_eq!(split.last(), whole.last(), "the letter after the mark stays joined");
|
||||
// Together with the mark, the letter before it has more glyphs, in the shaper's order.
|
||||
let (before, with_mark) = (split.first().unwrap(), whole.first().unwrap());
|
||||
assert!(before.iter().all(|g| with_mark.contains(g)), "and the letter before it: {before:?} {with_mark:?}");
|
||||
}
|
||||
|
||||
/// What can't join still doesn't: a tab between the letters, in one run or at the edge of a run, a
|
||||
/// Latin neighbour, and a letter beside a drop cap, which is drawn apart from the word.
|
||||
#[test]
|
||||
fn letters_stay_apart_where_nothing_joins_them() {
|
||||
if arabic_face().is_none() {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
}
|
||||
let alone = letters(&lay(&doc_of(&[("ب", true)])));
|
||||
assert_eq!(alone.len(), 1);
|
||||
let beh = alone.first();
|
||||
let tabbed = "ب\tب";
|
||||
for d in [doc_of(&[(tabbed, true)]), formatted(tabbed, &[(0, BEH)], &red), formatted(tabbed, &[(BEH + 1, tabbed.len())], &red)] {
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!((got.first(), got.last()), (beh, beh), "across a tab: {got:?}");
|
||||
}
|
||||
let got = letters(&lay(&formatted("xب", &[(0, 1)], &red)));
|
||||
assert_eq!(got.last(), beh, "after a Latin letter: {got:?}");
|
||||
|
||||
// The drop cap in the word's run, and formatted on its own (a run of its own).
|
||||
let two = letters(&lay(&doc_of(&[("بب", true)])));
|
||||
for mut d in [doc_of(&[("ببب", true)]), formatted("ببب", &[(0, BEH)], &red)] {
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.drop_cap = Some(2)).unwrap();
|
||||
let runs = d.para_at(&at).unwrap().runs.len();
|
||||
let got = letters(&lay(&d));
|
||||
assert_eq!(got.get(got.len().saturating_sub(2)..), two.get(..), "{runs} run(s): the letters after a drop cap start a word: {got:?} {two:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shaper_takes_a_letters_form_from_the_text_around_it() {
|
||||
use wordcraft_fonts::{ShapeContext, shape_run_in};
|
||||
let Some(face) = arabic_face() else {
|
||||
eprintln!("skipped: no font with Persian letters installed");
|
||||
return;
|
||||
};
|
||||
let form = |around| cluster_glyphs(&shape_run_in(&face, "ب", &[], |c| c, true, around));
|
||||
let isolated = form(ShapeContext::default());
|
||||
let joined = cluster_glyphs(&wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true));
|
||||
assert_eq!(form(ShapeContext { before: "", after: "ب" }).first(), joined.first(), "initial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "ب" }).first(), joined.get(1), "medial");
|
||||
assert_eq!(form(ShapeContext { before: "ب", after: "" }).first(), joined.get(2), "final");
|
||||
// A vowel sign is transparent, and ZWNJ and a Latin letter stop the join.
|
||||
assert_eq!(form(ShapeContext { before: "بَ", after: "" }).first(), joined.get(2), "past a mark");
|
||||
assert_eq!(form(ShapeContext { before: "ب\u{200C}", after: "" }), isolated, "after ZWNJ");
|
||||
assert_eq!(form(ShapeContext { before: "a", after: "b" }), isolated, "between Latin letters");
|
||||
// Only the letter's form depends on the context: nothing of it is drawn.
|
||||
assert_eq!(form(ShapeContext { before: "ببب", after: "ببب" }).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_that_join_their_letters_are_told_apart() {
|
||||
use wordcraft_fonts::joins_cursively;
|
||||
for c in ['ب', 'پ', 'ܐ', 'ᠠ', 'ߊ', '𞤀'] {
|
||||
assert!(joins_cursively(c), "{c}");
|
||||
}
|
||||
for c in ['a', 'é', '’', 'я', 'א', '中', ' ', '\t', '\u{FFFC}', '\u{200C}', 'َ'] {
|
||||
assert!(!joins_cursively(c), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs
|
||||
//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at
|
||||
//! the same place for 0 pt and 12 pt before.
|
||||
|
||||
use super::lay;
|
||||
use crate::{Arc, Placed, StoryRef};
|
||||
use wordcraft_doc::para::{Anchor, InlineObject};
|
||||
use wordcraft_doc::props::TableFloat;
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Table, para_block};
|
||||
|
||||
/// Where the lines of body block `block` start on page one.
|
||||
fn line_y(d: &Document, block: u32) -> f32 {
|
||||
lay(d).pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After".
|
||||
fn document(table: bool, text: &str, before: f32) -> Document {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
// "#" stands for a bookmark, a zero-width marker.
|
||||
if text == "#" {
|
||||
p = Paragraph::new();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap();
|
||||
}
|
||||
p.props.space_before = Some(before);
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))];
|
||||
if table {
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))];
|
||||
t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() });
|
||||
d.body.insert(0, Arc::new(Block::Table(t)));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() {
|
||||
let empty = line_y(&document(true, "", 12.0), 2);
|
||||
let plain = line_y(&document(false, "", 0.0), 1);
|
||||
assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}");
|
||||
// A bookmark alone doesn't make it a paragraph with text.
|
||||
assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01);
|
||||
// One with text keeps it, and so does an empty first paragraph with no table before it.
|
||||
assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99);
|
||||
assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99);
|
||||
}
|
||||
@@ -482,25 +482,3 @@ fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
|
||||
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
|
||||
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
|
||||
}
|
||||
|
||||
/// A paragraph of a decimal tab at 200 pt followed by `text`.
|
||||
fn after_decimal_tab(text: &str) -> Document {
|
||||
use wordcraft_doc::props::{TabAlign, TabStop};
|
||||
let mut d = Document::from_text(&format!("\t{text}"));
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p| p.tabs = Some(vec![TabStop { pos: 200.0, align: TabAlign::Decimal, leader: Default::default() }])).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// The text after a decimal tab was looked at again from the tab for its '.' after each of its
|
||||
/// clusters, so a long run of clusters without a '.' (soft hyphens take no room, so they stay on
|
||||
/// one line) took quadratic time.
|
||||
#[test]
|
||||
fn text_after_a_decimal_tab_is_looked_at_once() {
|
||||
let opts = LayoutOptions::default();
|
||||
// The text after the tab still ends at the stop, where it ended before.
|
||||
let l = lay(&after_decimal_tab("12.5"), &opts);
|
||||
let (Some(end), Some(start)) = (l.caret(&Pos::body(0, 5)), l.caret(&Pos::body(0, 0))) else { panic!("no carets") };
|
||||
assert!((end.x - start.x - 200.0).abs() < 1.0, "the text ends at the stop: {} from {}", end.x, start.x);
|
||||
assert_layout_linear("decimal tab", 2000, |n| after_decimal_tab(&format!("1{}", "\u{AD}".repeat(n))), &opts);
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
//! A row of a nested table marked "can't split" moves on with its outer row, as in Word
|
||||
//! (docxide-pdf `nested-first-row-*-cant1`): the outer row splits only above it.
|
||||
use crate::*;
|
||||
use wordcraft_doc::{Table, para_block};
|
||||
|
||||
fn one_line_paras(what: &str, n: usize) -> Vec<Arc<Block>> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut p = Paragraph::with_text(&format!("{what} {i}"), Default::default());
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.space_after = Some(0.0);
|
||||
para_block(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A filler paragraph pushed `push` points down, then a one-row table whose cells each hold
|
||||
/// `prefix` one-line paragraphs and a nested table of one row with `nested` of them. Returns,
|
||||
/// per cell, the prefix lines and the nested lines on each page.
|
||||
fn lines_per_page(push: f32, cells: &[(usize, usize)], cant_split: bool) -> Vec<(Vec<usize>, Vec<usize>)> {
|
||||
let mut d = Document::from_text("Filler");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
|
||||
p.props.space_before = Some(push);
|
||||
}
|
||||
let mut outer = Table::new(1, cells.len(), 440.0);
|
||||
for (cell, &(prefix, nested)) in outer.rows[0].cells.iter_mut().zip(cells) {
|
||||
let mut inner = Table::new(1, 1, 200.0);
|
||||
inner.rows[0].props.cant_split = cant_split;
|
||||
inner.rows[0].cells[0].blocks = one_line_paras("Nested", nested);
|
||||
cell.blocks = one_line_paras("Prefix", prefix);
|
||||
cell.blocks.push(Arc::new(Block::Table(inner)));
|
||||
cell.blocks.push(para_block(Paragraph::with_text("", Default::default())));
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
let count = |cell: u32, nested: bool| -> Vec<usize> {
|
||||
l.pages
|
||||
.iter()
|
||||
.map(|pg| {
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, l0, l1, .. } => {
|
||||
let p = &path.0;
|
||||
let hit = if nested {
|
||||
p.len() == 7 && p[..3] == [1, 0, cell] && p[4..6] == [0, 0]
|
||||
} else {
|
||||
p.len() == 4 && p[..3] == [1, 0, cell]
|
||||
};
|
||||
hit.then_some(l1 - l0)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
(0..cells.len() as u32).map(|c| (count(c, false), count(c, true))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_cant_split_row_moves_on_with_its_outer_row() {
|
||||
let split = &lines_per_page(520.0, &[(0, 20)], false)[0].1;
|
||||
assert!(split[0] > 0 && split[1] > 0, "a splittable nested row splits with its outer row: {split:?}");
|
||||
assert_eq!(lines_per_page(520.0, &[(0, 20)], true)[0].1[..2], [0, 20], "a can't-split nested row moves on whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_row_splits_above_a_nested_cant_split_row() {
|
||||
// About 22 lines of room: the nested row (lines 10 to 40) runs through it.
|
||||
let cell = &lines_per_page(340.0, &[(10, 30)], true)[0];
|
||||
assert!(cell.0[0] > 0, "the text above the nested row stays: {cell:?}");
|
||||
assert_eq!(cell.1[..2], [0, 30], "the nested row moves on whole: {cell:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cut_moves_up_until_no_nested_cant_split_row_runs_through_it() {
|
||||
// Room for about 22 lines. Only cell 0's nested row (lines 10 to 40) runs through it; the
|
||||
// cut at its top then runs through cell 1's (lines 5 to 13), which moves on whole too.
|
||||
let cells = lines_per_page(340.0, &[(10, 30), (5, 8)], true);
|
||||
assert_eq!(cells[0].1[..2], [0, 30], "{cells:?}");
|
||||
assert_eq!(cells[1].1[..2], [0, 8], "{cells:?}");
|
||||
assert!(cells[0].0[0] > 0 && cells[1].0[0] > 0, "the text above both stays: {cells:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_cant_split_row_taller_than_a_page_still_splits() {
|
||||
let pages = &lines_per_page(520.0, &[(0, 60)], true)[0].1;
|
||||
assert_eq!(pages[0], 0, "it leaves the partly filled page: {pages:?}");
|
||||
assert_eq!(pages.iter().sum::<usize>(), 60, "every line is placed once: {pages:?}");
|
||||
assert!(pages.iter().filter(|&&n| n > 0).count() >= 2, "it runs on over pages instead of into the margin: {pages:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outer_row_breaks_between_nested_cant_split_rows_above_its_bottom_band() {
|
||||
let mut nested = Table::new(40, 1, 200.0);
|
||||
nested.props.cell_margins = Some([0.0; 4]);
|
||||
for (i, row) in nested.rows.iter_mut().enumerate() {
|
||||
row.props.cant_split = true;
|
||||
row.cells[0].blocks = one_line_paras(&format!("Row {i}"), 2);
|
||||
}
|
||||
let mut outer = Table::new(1, 1, 400.0);
|
||||
// A bottom margin: the part left on the page keeps it below the last row there.
|
||||
outer.props.cell_margins = Some([0.0, 5.4, 4.0, 5.4]);
|
||||
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let pages = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 0, 0]);
|
||||
assert!(pages[0] > 0, "the rows that fit stay on the page: {pages:?}");
|
||||
assert_eq!(pages.iter().sum::<usize>(), 80, "every line is placed once: {pages:?}");
|
||||
for r in 0..40u32 {
|
||||
let on: Vec<usize> = per_page(&l, |p| p.len() == 7 && p[..5] == [1, 0, 0, 0, r]);
|
||||
assert_eq!(on.iter().filter(|&&n| n > 0).count(), 1, "row {r} stays on one page: {on:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn empty() -> Arc<Block> {
|
||||
para_block(Paragraph::with_text("", Default::default()))
|
||||
}
|
||||
|
||||
/// A nested table of one row, marked "can't split", with `lines` one-line paragraphs.
|
||||
fn kept_nested(lines: usize) -> Arc<Block> {
|
||||
let mut t = Table::new(1, 1, 200.0);
|
||||
t.rows[0].props.cant_split = true;
|
||||
t.rows[0].cells[0].blocks = one_line_paras("Nested", lines);
|
||||
Arc::new(Block::Table(t))
|
||||
}
|
||||
|
||||
/// Lay out a filler paragraph pushed `push` points down, then `outer`.
|
||||
fn lay_pushed(push: f32, outer: Table) -> DocLayout {
|
||||
let mut d = Document::from_text("Filler");
|
||||
if let Some(Block::Para(p)) = d.body.first_mut().map(Arc::make_mut) {
|
||||
p.props.space_before = Some(push);
|
||||
}
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
|
||||
let mut c = LayoutCache::new();
|
||||
layout(&d, &mut c, &LayoutOptions::default())
|
||||
}
|
||||
|
||||
/// The lines on each page of the body paragraphs whose path `hit` picks.
|
||||
fn per_page(l: &DocLayout, hit: impl Fn(&[u32]) -> bool) -> Vec<usize> {
|
||||
l.pages
|
||||
.iter()
|
||||
.map(|pg| {
|
||||
pg.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { path, l0, l1, .. } => hit(&path.0).then_some(l1 - l0),
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_aligned_cell_keeps_its_nested_cant_split_row_whole() {
|
||||
// Cell 1 sits at the bottom of the row as laid out, but the split runs it from its top,
|
||||
// where its nested row (lines 2 to 32) runs through the room of about 22 lines.
|
||||
let mut outer = Table::new(1, 2, 440.0);
|
||||
outer.rows[0].cells[0].blocks = one_line_paras("Tall", 50);
|
||||
outer.rows[0].cells[1].props.valign = Some(wordcraft_doc::props::VAlign::Bottom);
|
||||
let mut blocks = one_line_paras("Prefix", 2);
|
||||
blocks.extend([kept_nested(30), empty()]);
|
||||
outer.rows[0].cells[1].blocks = blocks;
|
||||
let l = lay_pushed(340.0, outer);
|
||||
let nested = per_page(&l, |p| p.len() == 7 && p[..4] == [1, 0, 1, 2]);
|
||||
assert_eq!(nested[..2], [0, 30], "the nested row moves on whole: {nested:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_rows_go_on_with_a_first_row_whose_nested_cant_split_row_comes_first() {
|
||||
let mut outer = Table::new(2, 1, 400.0);
|
||||
outer.rows[0].props.header = true;
|
||||
outer.rows[0].cells[0].blocks = one_line_paras("Header", 1);
|
||||
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let header = per_page(&l, |p| p == [1, 0, 0, 0]);
|
||||
assert_eq!(header[0], 0, "the header row doesn't stay alone at the page bottom: {header:?}");
|
||||
assert_eq!(per_page(&l, |p| p.len() == 7 && p[..4] == [1, 1, 0, 0])[..2], [0, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_row_goes_on_with_a_row_whose_nested_cant_split_row_cant_start_below_it() {
|
||||
let mut outer = Table::new(2, 1, 400.0);
|
||||
let mut kept = one_line_paras("Kept", 1);
|
||||
if let Some(Block::Para(p)) = kept.first_mut().map(Arc::make_mut) {
|
||||
p.props.keep_next = Some(true);
|
||||
}
|
||||
outer.rows[0].cells[0].blocks = kept;
|
||||
outer.rows[1].cells[0].blocks = vec![kept_nested(20), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let kept = per_page(&l, |p| p == [1, 0, 0, 0]);
|
||||
assert_eq!(kept[..2], [0, 1], "the kept row moves on with the next one: {kept:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merged_cell_doesnt_stretch_its_cant_split_row() {
|
||||
// Row 0 can't split; its first cell is merged down into row 1, which can and runs through
|
||||
// the room. The cut stays in row 1.
|
||||
let mut nested = Table::new(2, 2, 300.0);
|
||||
nested.rows[0].props.cant_split = true;
|
||||
nested.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
|
||||
nested.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
|
||||
nested.rows[0].cells[0].blocks = one_line_paras("Merged", 1);
|
||||
nested.rows[0].cells[1].blocks = one_line_paras("Short", 1);
|
||||
nested.rows[1].cells[1].blocks = one_line_paras("Long", 20);
|
||||
let mut outer = Table::new(1, 1, 400.0);
|
||||
outer.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), empty()];
|
||||
let l = lay_pushed(520.0, outer);
|
||||
let long = per_page(&l, |p| p.len() == 7 && p[..6] == [1, 0, 0, 0, 1, 1]);
|
||||
assert!(long[0] > 0 && long[1] > 0, "the splittable row splits: {long:?}");
|
||||
}
|
||||
@@ -208,7 +208,6 @@ fn links_out_of_a_root_are_refused() {
|
||||
}
|
||||
|
||||
/// The names in `dir`, sorted.
|
||||
#[cfg(unix)]
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
|
||||
+21
-44
@@ -166,15 +166,6 @@ impl Exporter<'_> {
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
// A turned picture cut at its line's top and bottom.
|
||||
Draw::Clip { rect, items } => {
|
||||
let Some(clip) = rect_path(rect) else { return };
|
||||
s.push_clip_path(&clip, &FillRule::NonZero);
|
||||
for it in items {
|
||||
self.rotated_pictures(s, it);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| {
|
||||
@@ -186,40 +177,6 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// One draw of a paragraph's line, tagged for what it is: text as the paragraph's content
|
||||
/// (structure entry `idx`), pictures and figures on their own, anything else an artifact.
|
||||
fn line_item(&mut self, s: &mut Surface, d: &Draw, idx: usize) {
|
||||
match d {
|
||||
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// A picture marked decorative: an artifact screen readers skip.
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
// A rotated inline picture, or a rotated group holding pictures: turned as a
|
||||
// whole, each picture tagged on its own (see `rotated_pictures`).
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
|
||||
// A clip (a turned picture cut at its line, an exact-height cell's lines, a canvas's
|
||||
// members): each draw in it tagged as it would be outside it.
|
||||
Draw::Clip { rect, items } => {
|
||||
let Some(clip) = rect_path(rect) else { return };
|
||||
s.push_clip_path(&clip, &FillRule::NonZero);
|
||||
for it in items {
|
||||
self.line_item(s, it, idx);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
// A turned text box's line: still the paragraph's text. A rotated shape
|
||||
// without text falls to the artifacts below.
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_text) => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
|
||||
for d in inner {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) {
|
||||
if let Some(c) = self.doc.settings.page_color
|
||||
&& let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h))
|
||||
@@ -264,7 +221,27 @@ impl Exporter<'_> {
|
||||
Placed::Lines { story, path, para, .. } => {
|
||||
let idx = self.entry(*story, path, para);
|
||||
for d in &draws {
|
||||
self.line_item(s, d, idx);
|
||||
match d {
|
||||
Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
|
||||
// A picture marked decorative: an artifact screen readers skip.
|
||||
Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)),
|
||||
// A rotated inline picture, or a rotated group holding pictures: turned as a
|
||||
// whole, each picture tagged on its own (see `rotated_pictures`).
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
|
||||
// A turned text box's line: still the paragraph's text. A rotated shape
|
||||
// without text falls to the artifacts below.
|
||||
Draw::Rotated { items, .. } if items.iter().any(holds_text) => {
|
||||
self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d))
|
||||
}
|
||||
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
|
||||
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
|
||||
for d in inner {
|
||||
me.draw(s, d);
|
||||
}
|
||||
}),
|
||||
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Placed::Image { story, path, off, .. } => {
|
||||
|
||||
@@ -8,10 +8,7 @@ pub(crate) fn metadata(doc: &Document, opts: &PdfOptions) -> Metadata {
|
||||
if !title.trim().is_empty() {
|
||||
m.title = Some(title);
|
||||
}
|
||||
// "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);
|
||||
let author = opts.author.clone().unwrap_or_else(|| c.creator.clone());
|
||||
if !author.trim().is_empty() {
|
||||
m.authors = vec![author];
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ pub(crate) fn graphic_alt(alt: &str, kind: GraphicKind) -> &str {
|
||||
if alt.trim().is_empty() { kind.noun() } else { alt }
|
||||
}
|
||||
|
||||
/// A picture, or a rotation or clip holding one at any depth (a rotated group's turned members, a
|
||||
/// turned picture cut at its line).
|
||||
/// A picture, or a rotation holding one at any depth (a rotated group's turned members).
|
||||
pub(crate) fn holds_picture(d: &Draw) -> bool {
|
||||
match d {
|
||||
Draw::Image { .. } => true,
|
||||
Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_picture),
|
||||
Draw::Rotated { items, .. } => items.iter().any(holds_picture),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user