Merge remote-tracking branch 'origin/main' into codex/toc-dialog-title-width

# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
This commit is contained in:
moppeeer
2026-10-24 14:19:43 +02:00
210 changed files with 9910 additions and 2815 deletions
+23 -2
View File
@@ -128,8 +128,14 @@ pub fn write(swatches: &[Swatch]) -> Vec<u8> {
continue;
}
let mut b = Vec::new();
let units: Vec<u16> = s.name.encode_utf16().chain(std::iter::once(0)).collect();
b.extend((units.len() as u16).to_be_bytes());
// The length field is a u16 that counts the terminating NUL: truncate longer names.
let mut units: Vec<u16> = s.name.encode_utf16().take(usize::from(u16::MAX) - 1).collect();
// Do not leave half of a surrogate pair at the cut.
if units.last().is_some_and(|u| (0xD800..0xDC00).contains(u)) {
units.pop();
}
units.push(0);
b.extend(u16::try_from(units.len()).unwrap_or(u16::MAX).to_be_bytes());
for u in units {
b.extend(u.to_be_bytes());
}
@@ -233,6 +239,21 @@ mod tests {
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
#[test]
fn over_long_name_is_truncated_to_fit() {
let sw = vec![Swatch {
name: "x".repeat(70_000),
value: SwatchValue::Color { color: Color::Gray { k: 0.5 }, color_type: ColorType::Process },
locked: false,
named: true,
hidden: false,
}];
let back = read(&write(&sw)).unwrap();
assert_eq!(back.len(), 1, "the swatch must survive the round trip");
assert_eq!(back[0].name, "x".repeat(usize::from(u16::MAX) - 1));
assert_eq!(back[0].value, sw[0].value);
}
#[test]
fn out_of_range_components_are_clamped() {
let mut f = Vec::new();
+10 -10
View File
@@ -17,7 +17,7 @@ pub mod lab;
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use serde::{Deserialize, Serialize};
@@ -201,7 +201,7 @@ static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(p) = c.iter().find(|p| p.name == name) {
return Some(p.clone());
}
@@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
}
fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned()
}
/// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable.
@@ -255,7 +255,7 @@ pub fn profiles() -> Vec<ProfileInfo> {
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
}
v
@@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
return Err(CmsError::Unsupported(format!("{}: no usable transform to/from sRGB", p.name)));
}
let info = ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false };
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
let mut u = USER.write().unwrap_or_else(PoisonError::into_inner);
u.retain(|x| x.name != info.name);
u.push(Arc::new(p));
drop(u);
@@ -522,7 +522,7 @@ impl Cms {
/// A cached 3-D lookup table implementing [`Cms::proof_srgb`] for 8-bit pixels.
pub fn proof_lut(&self, proof: &ProofSetup) -> Arc<ProofLut> {
let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper);
let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) {
return l.clone();
}
@@ -538,7 +538,7 @@ impl Cms {
static PROOF_CACHE: Mutex<Vec<(String, Arc<ProofLut>)>> = Mutex::new(Vec::new());
fn clear_proof_cache() {
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear();
}
fn cmyk4(v: Option<Vec<f32>>) -> Option<[f32; 4]> {
@@ -704,10 +704,10 @@ pub fn generation() -> u64 {
/// The active colour-management context (Edit → Color Settings).
pub fn active() -> Arc<Cms> {
if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() {
return c.clone();
}
let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner());
let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner);
w.get_or_insert_with(|| Arc::new(Cms::default())).clone()
}
@@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> {
let changed = active().settings != *settings;
RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed);
CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed);
*ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms));
*ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms));
if changed {
GENERATION.fetch_add(1, Ordering::Relaxed);
}
+1 -1
View File
@@ -295,7 +295,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool {
}
pub fn is_forced(c: char) -> bool {
use designcraft_doc::story::*;
use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char};
c == FORCED_LINE_BREAK || is_break_char(c)
}
+29 -16
View File
@@ -7,7 +7,7 @@
//! can't be reused by other allocations while the entry lives.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use designcraft_doc::{Document, Item, Story, StoryId, Styles};
@@ -186,7 +186,7 @@ impl Cache {
}
// Advanced Type sizes super/subscripts.
let a = doc.settings.advanced_type;
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize));
sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].into_iter().flat_map(bits));
// Missing glyphs: fallback fonts or the font's box.
sig.push(usize::from(doc.settings.glyph_fallback));
// Named lists continue from earlier stories.
@@ -196,7 +196,7 @@ impl Cache {
sig.push(std::hash::Hasher::finish(&h) as usize);
}
{
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
g.1 += 1;
let stamp = g.1;
if let Some(e) = g.0.get_mut(&key)
@@ -216,7 +216,7 @@ impl Cache {
&ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) },
&others.iter().map(|o| &**o).collect::<Vec<_>>(),
));
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
let stamp = g.1;
if g.0.len() > 4096 {
g.0.retain(|_, e| stamp - e.stamp < 64);
@@ -228,7 +228,7 @@ impl Cache {
&& cross
&& let Some(old) = replaced
{
self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out));
self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out));
}
out
}
@@ -241,7 +241,7 @@ impl Cache {
// Only the geometry/story part of the signature (variables etc. add to it).
let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..];
{
let g = self.map.lock().unwrap_or_else(|e| e.into_inner());
let g = self.map.lock().unwrap_or_else(PoisonError::into_inner);
for cross in [true, false] {
if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross))
&& matches(&e.sig)
@@ -250,7 +250,7 @@ impl Cache {
}
}
}
let g = self.previous.lock().unwrap_or_else(|e| e.into_inner());
let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner);
g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone())
}
@@ -306,38 +306,45 @@ impl Cache {
}
pub fn clear(&self) {
self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear();
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = None;
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None;
self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear();
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = None;
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
/// Anchor pages for cross-references (rebuilt when the document changes).
pub fn xref_index(&self, doc: &Document) -> Arc<crate::xref::XrefIndex> {
let sig = doc_signature(doc);
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner())
if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None)));
*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
/// The running-header index for `doc` (rebuilt when the document changes).
pub fn running_index(&self, doc: &Document) -> Arc<RunningIndex> {
let sig = doc_signature(doc);
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner())
if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner)
&& *s == sig
{
return r.clone();
}
let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None)));
*self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone()));
*self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone()));
r
}
}
/// Both halves of `v`'s bits: a 32-bit `usize` (wasm) would drop the high half, where whole
/// numbers keep their differences.
fn bits(v: f64) -> [usize; 2] {
let b = v.to_bits();
[(b & 0xffff_ffff) as usize, (b >> 32) as usize]
}
fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>) {
// The document's font scope: another document's fonts of the same name set it differently.
// Sections name the pages under the frames (page-number and section markers).
@@ -348,9 +355,9 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
Arc::as_ptr(&doc.styles) as usize,
sections_hash(doc) as usize,
doc.font_scope as usize,
g.start.to_bits() as usize,
g.increment.to_bits() as usize,
usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin),
];
sig.extend([g.start, g.increment].into_iter().flat_map(bits));
let mut keep = Vec::new();
let mut spreads = Vec::new();
for f in &story.frames {
@@ -373,6 +380,12 @@ fn signature(doc: &Document, story: &Arc<Story>) -> (Vec<usize>, Vec<Arc<Item>>)
sig.push(loc.top());
if !spreads.contains(&loc.spread) {
spreads.push(loc.spread);
// The page under a frame (found by x) gives its grid origin, page rect and side.
for p in &sp.pages {
let m = &p.margins;
sig.extend([p.x, p.width, p.height, m.top, m.bottom, m.inside, m.outside].into_iter().flat_map(bits));
sig.push(p.side as usize);
}
for it in &sp.items {
let mut wraps = false;
it.walk(&mut |i| wraps |= i.has_wrap());
+161 -36
View File
@@ -165,6 +165,20 @@ pub struct PlacedGlyph {
}
impl PlacedGlyph {
/// Whether an underline or a strikethrough runs across this glyph.
///
/// A tab is not drawn, but it is a character with an advance, and the rule runs across its
/// whole width as InDesign draws it: forms rule their fill-in lines with underlined tabs, and
/// without this they lost every writing line (#1466). The spaces a line break trimmed off the
/// end, the break characters themselves and the markers' carriers are not part of the line's
/// text and are not spanned.
pub fn spans_rules(&self) -> bool {
if self.visible {
return true;
}
self.adv > 0.0 && matches!(self.rendered_char, '\t' | designcraft_doc::story::RIGHT_INDENT_TAB)
}
/// In a vertical frame, the turn that sets this glyph upright, applied after drawing it at `x`
/// on `baseline`: it hangs from its vertical origin ([`designcraft_fonts::FontFace::v_origin`],
/// centred across) at `x` on the line's centre, the middle of the font's em box; a tate-chu-yoko
@@ -569,6 +583,9 @@ pub struct FrameSpec {
pub grid: Option<(f64, f64)>,
/// The frame is on a left page (for towards/away-from-spine alignment).
pub left_page: bool,
/// The frame is on a right page of a facing spread (spine-relative anchored objects mirror
/// there; a single page has no spine).
pub right_page: bool,
/// The page and its margins in the frame's inner space (custom anchored objects).
pub page_rect: Option<(Rect, Rect)>,
}
@@ -648,7 +665,10 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
let loc = doc.find(fid);
let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue };
let Some(tf) = item.text_frame() else { continue };
let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf;
let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY);
let xf = parent * item.xf;
// Where the frame is on the spread (a group's transform included), to find its page.
let center_x = parent.transform_rect_bbox(item.bounds()).center().x;
let inv = xf.inverse();
let mut exclusions = Vec::new();
let spread_ref = loc.as_ref().map(|l| l.spread);
@@ -672,34 +692,31 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0
});
}
let (page_name, page, left_page) = match (spread_ref, spread) {
let (page_name, page, side) = match (spread_ref, spread) {
(Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => {
let c = item.bounds().center();
let pi = sp.page_at_x(c.x).unwrap_or(0);
let pi = sp.page_at_x(center_x).unwrap_or(0);
let abs = doc.first_page_of_spread(si) + pi;
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left))
(Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side))
}
(Some(designcraft_doc::SpreadRef::Parent(pi)), Some(sp)) => {
(Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => {
let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into());
let _ = pi;
(Some(prefix), None, false)
// The side of the parent page under the frame, so a folio there aligns to the spine of its own page.
let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi));
(Some(prefix), None, under.map(|p| p.side))
}
_ => (None, None, false),
_ => (None, None, None),
};
let left_page = side == Some(designcraft_doc::PageSide::Left);
let right_page = side == Some(designcraft_doc::PageSide::Right);
let section_marker = page.map(|abs| section_marker(doc, abs));
// The page under the frame and its margins, in inner space (bounding boxes).
let page_rect = match (spread_ref, spread) {
(_, Some(sp)) => sp
.page_at_x(item.bounds().center().x)
.and_then(|pi| sp.pages.get(pi))
.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))),
_ => None,
};
// The page under the frame, and its bounds and margins in inner space (bounding boxes).
let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)));
let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect())));
let g = &doc.settings.baseline_grid;
let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start));
let (start, inc) = tf.options.baseline_grid.map_or((under.map_or(g.start, |pg| g.origin(pg)), g.increment), |f| (f.start, f.increment));
// Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only).
let ty = xf.translation().y;
let grid = (inc > 0.0).then_some((start - ty, inc));
let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc));
// Type on a path: one line as long as the path (from the start offset).
let (area, opts) = match &tf.options.path {
Some(pt) => {
@@ -757,6 +774,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec<FrameSpec> {
page,
grid,
left_page,
right_page,
page_rect,
});
}
@@ -845,6 +863,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec<ParaInfo> = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec<Option<usize>> = vec![None; np];
// Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid
// without, because a line ending at one of them ended a column or frame. Each re-lay leaves
// out one more point of the paragraph, so they are bounded by its points; the cap only guards
// against keep options clearing them over and over.
let mut no_hyph: Vec<Vec<usize>> = vec![Vec::new(); np];
let mut hyph_relays = 0usize;
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -884,7 +908,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -894,7 +918,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
&& let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget)
{
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -948,7 +972,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1083,7 +1107,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
apply_desired_spacing(&mut gl, &pp);
mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi);
let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 {
let tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 };
@@ -1218,7 +1243,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
apply_desired_spacing(&mut glyphs, &pp);
mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi);
let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
let mut hyph_after =
hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars));
suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice));
let base_size = base_chars.size;
let spacing = spacing_for(&pp, base_size);
// An empty paragraph takes its height from its paragraph mark's character format.
@@ -1325,7 +1352,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
trial = Some(t);
balance_runs += 1;
rewind(key.1, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.1,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.1;
continue 'paras;
}
@@ -1361,6 +1399,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut line_no = 0usize;
// Paragraph line number of the first line in the current column.
let mut col_first_line = 0usize;
// The story byte at which the last line set ended at an automatic hyphenation point.
let mut hyphen_at: Option<usize> = None;
let has_tabs = glyphs.iter().any(|g| g.ch == '\t');
let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span
let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft;
@@ -1378,7 +1418,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if cur.fi >= frames.len() {
if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) {
balance_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
pi = j;
continue 'paras;
}
@@ -1605,6 +1645,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
0.0
};
let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped;
if !fits
&& !pp.hyph_across_column
&& line_no > col_first_line
&& let Some(at) = hyphen_at
&& hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1))
{
hyph_relays += 1;
no_hyph[pi].push(at);
rewind(pi, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
continue 'paras;
}
if !fits {
if cur.can_next_sub() {
g0 = s;
@@ -1626,7 +1677,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
};
if let Some(j) = j {
split_runs += 1;
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1659,7 +1721,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
j
}
};
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1674,10 +1747,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
}
// A drop cap sits at the start-side indent; the first-line indent doesn't move it.
let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent);
let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 };
let (ind_l, ind_r) = match beside {
Some(w) if rtl => (pp.left_indent, pp.right_indent + w),
Some(w) => (pp.left_indent + w, pp.right_indent),
None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent),
// The first-line indent is at the paragraph's start side, which is the right
// one in a right-to-left paragraph: applied on the left it moved the line the
// wrong way, out of the frame for the hanging indent a numbered list uses
// (#1448).
None if rtl => (pp.left_indent, pp.right_indent + first_line),
None => (pp.left_indent + first_line, pp.right_indent),
};
let lx0 = x0 + ind_l;
let lx1 = x1 - ind_r;
@@ -1765,7 +1844,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r)
&& let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para)
{
rewind(j, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = j;
continue 'paras;
}
@@ -1797,6 +1887,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
cur.last_descent = desc;
cur.pending = 0.0;
cur.grid_next = grid_end.map(|end| (baseline, end));
hyphen_at =
e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len);
line_no += 1;
// A column / frame / page break inside the paragraph: the rest of it continues in the
// new column/frame/page, re-broken there. (A break that ends the paragraph moves the
@@ -1924,7 +2016,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
if !t.done {
split_trial = Some(t);
split_runs += 1;
rewind(key.2, &snaps, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.2,
&snaps,
&note_snaps,
&mut notes,
&mut out,
&mut cur,
&mut list_counter,
&mut force_col,
&mut line_cap,
&mut no_hyph,
);
pi = key.2;
continue 'paras;
}
@@ -1982,6 +2085,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
object_point,
ref_point,
keep_within_column,
spine_relative,
} => {
use designcraft_doc::anchored::AnchorRelative as R;
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
@@ -1993,15 +2097,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
};
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
let (rfx, rfy) = frac(*ref_point);
let (ofx, ofy) = frac(*object_point);
let (mut rfx, rfy) = frac(*ref_point);
let (mut ofx, ofy) = frac(*object_point);
let mut dx = *x_offset;
// The settings describe a left-hand page; a right-hand page mirrors them
// across the spine.
if *spine_relative && f.right_page {
(rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx);
}
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
let mut y = ry + y_offset - h * ofy;
if *keep_within_column {
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
}
Point::new(rx + x_offset - w * ofx, y)
Point::new(rx + dx - w * ofx, y)
}
};
}
@@ -2282,9 +2392,11 @@ fn rewind(
list_counter: &mut ListCounter,
force_col: &mut [bool],
line_cap: &mut [Option<usize>],
no_hyph: &mut [Vec<usize>],
) {
force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);
line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None);
no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear);
if let Some(s) = snaps.get(j) {
s.restore(out, cur, list_counter);
}
@@ -3984,6 +4096,18 @@ fn foreign_ranges(
.collect()
}
/// Leave out the hyphenation points after the glyphs that end at story bytes `at`.
fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) {
if at.is_empty() {
return;
}
for (h, g) in hy.iter_mut().zip(glyphs) {
if *h && at.contains(&(g.byte + g.len)) {
*h = false;
}
}
}
fn hyphenation_points(
text: &str,
glyphs: &[Glyph],
@@ -4004,7 +4128,7 @@ fn hyphenation_points(
// Words repeat a lot: remember their points per set of limits.
static CACHE: Mutex<Vec<(LimitsKey, HashMap<(hyphen::Lang, Box<str>), Box<[usize]>>)>> = Mutex::new(Vec::new());
let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized);
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let idx = match guard.iter().position(|e| e.0 == key) {
Some(i) => i,
None => {
@@ -4505,7 +4629,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
// count as still inside the note.
let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01;
let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y });
if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) }
if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) }
}
#[cfg(test)]
@@ -4627,6 +4751,7 @@ mod column_allocation_safety_tests {
page: None,
grid: None,
left_page: false,
right_page: false,
page_rect: None,
outline: None,
}
+4 -2
View File
@@ -72,7 +72,7 @@ pub(crate) struct Notes {
pub key: Option<usize>,
pub placed: Vec<Placed>,
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
cache: HashMap<(usize, String, u64, bool, bool), (Arc<ComposedStory>, NoteMetrics)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
/// Footnotes span the columns of their frame.
span: bool,
@@ -153,7 +153,8 @@ impl Notes {
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
let key = (k, label.clone(), w.to_bits());
// The page side sets towards/away-from-spine alignment and spine-relative anchored objects.
let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page);
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
@@ -169,6 +170,7 @@ impl Notes {
page: f.page,
grid: None,
left_page: f.left_page,
right_page: f.right_page,
page_rect: None,
};
let nopts = ComposeOptions {
+43 -32
View File
@@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option<ParaBox>], frames: &
}
}
/// The shading box for one part of a paragraph run, if it has any area.
fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: bool, last_part: bool) -> Option<Deco> {
let r = inner_rect(&part.lines, s.width, s.top, s.bottom)?;
let [t, l, bo, rr] = s.offsets;
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
if s.clip
&& let Some(f) = frames.get(part.fi)
{
rect = rect.intersect(frame_rect(f));
}
if rect.width() > 0.0 && rect.height() > 0.0 {
// Corners stay square where the box is cut by a split.
let corners = cut_corners(s.corners, !first_part, !last_part);
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting });
}
None
}
/// The border strokes (and plain edges) for one part of a paragraph run, pushed onto `decos`.
fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, decos: &mut Vec<(usize, bool, Deco)>) {
let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) else { return };
let [t, l, bo, rr] = bd.offsets;
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
let open_top = !first_part && !bd.display_if_splits;
let open_bottom = !last_part && !bd.display_if_splits;
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
}
if is_plain(bd, open_top, open_bottom) {
decos.extend(
plain_edges(bd, o, open_top, open_bottom)
.into_iter()
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
);
}
}
/// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco).
fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> {
let mut parts: Vec<Part<'_>> = Vec::new();
@@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive<usize>, b: &
for (k, part) in parts.iter().enumerate() {
let (first_part, last_part) = (k == 0, k + 1 == n);
if let Some(s) = &b.shading
&& let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom)
&& let Some(deco) = shading_deco(s, part, frames, first_part, last_part)
{
let [t, l, bo, rr] = s.offsets;
let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
if s.clip
&& let Some(f) = frames.get(part.fi)
{
rect = rect.intersect(frame_rect(f));
}
if rect.width() > 0.0 && rect.height() > 0.0 {
// Corners stay square where the box is cut by a split.
let corners = cut_corners(s.corners, !first_part, !last_part);
let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4]));
decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }));
}
decos.push((part.fi, true, deco));
}
if let Some(bd) = &b.border
&& let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom)
{
let [t, l, bo, rr] = bd.offsets;
let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo);
let open_top = !first_part && !bd.display_if_splits;
let open_bottom = !last_part && !bd.display_if_splits;
for (path, gap) in border_paths(bd, o, open_top, open_bottom) {
let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) };
decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false }));
}
if is_plain(bd, open_top, open_bottom) {
decos.extend(
plain_edges(bd, o, open_top, open_bottom)
.into_iter()
.map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })),
);
}
if let Some(bd) = &b.border {
border_decos(bd, part, first_part, last_part, &mut decos);
}
}
decos
+31 -25
View File
@@ -71,6 +71,36 @@ fn tcy_char(c: char, roman: bool) -> bool {
c.is_ascii_digit() || (roman && c.is_ascii_alphabetic())
}
/// Tate-chu-yoko in vertical ruby: each short run of digits (and Latin letters) is set in one
/// ruby em across the line, scaled down to fit when the spec asks for it.
fn apply_tcy(glyphs: &mut [RubyGlyph], spec: &RubySpec, size: f64) {
let roman = spec.auto_tcy_include_roman;
let mut i = 0;
while i < glyphs.len() {
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
if j == i {
i += 1;
continue;
}
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
&& let Some(run) = glyphs.get_mut(i..j)
{
let total: f64 = run.iter().map(|g| g.adv).sum();
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
let mut across = -total * f / 2.0;
for (n, g) in run.iter_mut().enumerate() {
g.tcy = Some([size / 2.0, across + g.x * f, size]);
across += g.adv * f;
g.sx *= f;
g.x = 0.0;
g.adv = if n == 0 { size } else { 0.0 };
g.joined = n > 0;
}
}
i = j;
}
}
/// Shape `text` as the ruby of parent text `parent_size` points big.
fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped {
let size = spec.size_for(parent_size);
@@ -96,31 +126,7 @@ fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec
.collect();
// Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line.
if vertical && spec.auto_tcy_digits > 0 {
let roman = spec.auto_tcy_include_roman;
let mut i = 0;
while i < glyphs.len() {
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
if j == i {
i += 1;
continue;
}
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
&& let Some(run) = glyphs.get_mut(i..j)
{
let total: f64 = run.iter().map(|g| g.adv).sum();
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
let mut across = -total * f / 2.0;
for (n, g) in run.iter_mut().enumerate() {
g.tcy = Some([size / 2.0, across + g.x * f, size]);
across += g.adv * f;
g.sx *= f;
g.x = 0.0;
g.adv = if n == 0 { size } else { 0.0 };
g.joined = n > 0;
}
}
i = j;
}
apply_tcy(&mut glyphs, spec, size);
}
// Pen positions: a joined glyph sits at its group's start.
let mut pen = 0.0;
+2 -2
View File
@@ -950,10 +950,10 @@ static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Ve
const WORD_CACHE_MAX: usize = 50_000;
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) {
return m.clone();
}
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
return m.clone();
}
+27 -22
View File
@@ -121,7 +121,7 @@ impl CellLineExtent {
}
/// Compose a cell story at width `w` (content space, top-left at the origin).
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout {
fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout {
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
@@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
page: opts.page,
grid: None,
left_page,
right_page,
page_rect: None,
};
let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts);
@@ -200,7 +201,7 @@ pub(crate) fn place_table(
let (nr, nc) = (table.nrows(), table.ncols());
let owners = table.owners();
let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c);
let left_page = frames[cur.fi].left_page;
let side = (frames[cur.fi].left_page, frames[cur.fi].right_page);
// Compose every owner cell.
let mut layouts: Vec<Option<CellLayout>> = Vec::with_capacity(nr * nc);
for r in 0..nr {
@@ -212,7 +213,7 @@ pub(crate) fn place_table(
let cell = &table.cells[r * nc + c];
let cs = (cell.col_span.max(1) as usize).min(nc - c);
let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3];
layouts.push(Some(compose_cell(doc, cell, w, opts, left_page)));
layouts.push(Some(compose_cell(doc, cell, w, opts, side)));
}
}
// Row heights.
@@ -342,6 +343,26 @@ pub(crate) fn place_table(
true
}
/// A cell's text cut to the lines that fit `clip_height`, with where the overset text begins.
/// Uses the same baseline/bounds test as auto-growth, and retains a prefix: overset text cannot
/// reappear after a line that did not fit.
fn clip_overset(lay: &CellLayout, clip_height: f64) -> Arc<ComposedStory> {
let Some(f) = lay.text.frames.first() else { return lay.text.clone() };
let keep = lay.line_heights.iter().take_while(|h| **h <= clip_height + 0.01).count();
let overset_at = f.lines.get(keep).map(|l| l.range.start);
let mut t = (*lay.text).clone();
if let Some(frame) = t.frames.first_mut() {
frame.lines.truncate(keep);
frame.objects.retain(|o| o.line < keep);
frame.tables.retain(|table| table.line < keep);
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
}
if let Some(at) = overset_at {
t.overset_at = Some(at);
}
Arc::new(t)
}
/// Build one fragment from its rows and push it (with its glyph-less line) to the current frame.
#[allow(clippy::too_many_arguments)]
fn emit(
@@ -406,24 +427,7 @@ fn emit(
_ => 0.0,
};
let overset = space < -0.01 || lay.text.is_overset();
let mut text = lay.text.clone();
if overset && let Some(f) = text.frames.first() {
// Use the same baseline/bounds test as auto-growth, and retain a prefix:
// overset text cannot reappear after a line that did not fit.
let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count();
let overset_at = f.lines.get(keep).map(|l| l.range.start);
let mut t = (*text).clone();
if let Some(frame) = t.frames.first_mut() {
frame.lines.truncate(keep);
frame.objects.retain(|o| o.line < keep);
frame.tables.retain(|table| table.line < keep);
frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end);
}
if let Some(at) = overset_at {
t.overset_at = Some(at);
}
text = Arc::new(t);
}
let text = if overset { clip_overset(lay, clip.height()) } else { lay.text.clone() };
cells.push(PlacedCell {
row: r,
col: c,
@@ -813,7 +817,7 @@ mod height_tests {
}
fn measure(cell: &Cell, width: f64) -> CellLayout {
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false)
compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false))
}
fn compose_table(table: Table, height: f64) -> ComposedStory {
@@ -1061,6 +1065,7 @@ mod height_tests {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
}),
);
assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6);
+350 -2
View File
@@ -1013,6 +1013,92 @@ fn hyphenation_zone_limits_ragged_hyphens() {
assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}");
}
#[test]
fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() {
let text = CORPUS.join(" ");
// Column-end lines that are hyphenated, over frame heights that end the first column at each
// line in turn.
let ends = |across: bool, composer: designcraft_doc::Composer| {
let mut ends = 0;
let mut lines = 0;
for h in (30..400).step_by(3) {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
composer: Some(composer),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let cs = compose_story(&d, sid, &ComposeOptions::default());
let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect();
ends += usize::from(col0.last().is_some_and(|l| l.hyphenated));
lines += col0.len();
// The frame's last line too, when the text goes on (overset).
let last = all_lines(&cs).into_iter().last();
ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para));
}
(ends, lines)
};
for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] {
let (on, lines_on) = ends(true, c);
let (off, lines_off) = ends(false, c);
// With it on, columns do end in hyphens here; with it off, none does, and the columns
// still fill.
assert!(on > 3, "{c:?}: {on}");
assert_eq!(off, 0, "{c:?}");
assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}");
}
}
#[test]
fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
// The first line in a character style of another colour (the same widths): when its hyphen is
// left out at a column end, the style still covers exactly the first line as set.
let text = CORPUS.join(" ");
let compose = |w: f64, h: f64, across: bool| {
let para = ParaAttrs {
align: Some(Align::Left),
hyph_weight: Some(0.0),
hyph_across_column: Some(across),
nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]),
..Default::default()
};
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para);
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() };
std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle {
name: "Lead".into(),
based_on: None,
chars,
shortcut: String::new(),
});
compose_story(&d, sid, &ComposeOptions::default())
};
let mut checked = 0;
for w in (160..320).step_by(10) {
for h in (12..40).step_by(2) {
// Where, with the setting on, a hyphenated first line ends column 1.
let on = compose(w as f64, h as f64, true);
let lines = all_lines(&on);
if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) {
continue;
}
let cs = compose(w as f64, h as f64, false);
let lines = all_lines(&cs);
assert!(!lines[0].hyphenated, "{w}×{h}");
let lead = |l: &Line| {
l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::<Vec<_>>()
};
assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line");
assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line");
checked += 1;
}
}
assert!(checked > 3, "{checked}");
}
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
@@ -1150,7 +1236,7 @@ fn nonbreaking_space_alone_fills_a_justified_line() {
/// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame.
fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec<usize>) {
let mut parts: Vec<String> = (0..fillers).map(|k| format!("Filler {k}")).collect();
parts.extend(extra.iter().map(|s| s.to_string()));
parts.extend(extra.iter().map(ToString::to_string));
let text = parts.join("\n");
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, height), ParaAttrs::default());
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
@@ -2752,6 +2838,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos));
let cs = compose_story(&d, sid, &ComposeOptions::default());
@@ -6610,7 +6697,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() {
fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec<Line>, (f64, f64)) {
let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para);
if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) {
tf.options.baseline_grid = Some((12.0, 0.0));
tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 });
}
edit(&mut d, sid);
// Vertical frames have no baseline grid.
@@ -6740,6 +6827,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel:
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position));
}
@@ -6808,6 +6896,7 @@ fn anchored_object_wraps_with_its_contour_and_side() {
object_point: 0,
ref_point: 0,
keep_within_column: false,
spine_relative: false,
};
d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position));
let cs = compose_story(&d, b, &ComposeOptions::default());
@@ -6821,6 +6910,70 @@ fn anchored_object_wraps_with_its_contour_and_side() {
assert!(top > mid + 5.0, "top {top}, middle {mid}");
}
/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of
/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the
/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a
/// left-hand page, the right page's note sat in the inside margin and reached across the spine.
#[test]
fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() {
use designcraft_doc::anchored::AnchorRelative as R;
let compose_spread = |spine_relative: bool| {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let sp = &d.spreads[1];
let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone());
assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right));
// Body frames leave a 120 pt outside margin for the notes.
let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0);
let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0);
let text = [LOREM; 3].join(" ");
let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap();
let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap();
d.thread(lf, rf).unwrap();
let plain = compose_story(&d, sid, &ComposeOptions::default());
let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start;
let (at_left, at_right) = (second_line(lf), second_line(rf));
// Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline.
let position = designcraft_doc::AnchorPosition::Custom {
x_relative: R::ColumnEdge,
y_relative: R::Anchor,
x_offset: -12.0,
y_offset: 0.0,
object_point: 2,
ref_point: 0,
keep_within_column: false,
spine_relative,
};
for at in [at_right, at_left] {
let note = designcraft_doc::Item::new(
ItemId(d.alloc()),
lid,
designcraft_doc::Shape::Rectangle,
designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)),
);
d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone()));
}
let cs = compose_story(&d, sid, &ComposeOptions::default());
// Per page: the note's left and right edges, the column, and the page's left and right edges.
let placed = |fid, page: &designcraft_doc::Page| {
let ft = cs.frame(fid).unwrap();
assert_eq!(ft.objects.len(), 1);
let o = &ft.objects[0];
((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width))
};
(placed(lf, &l), placed(rf, &r))
};
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true);
assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}");
assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}");
// Without Relative to Spine both pages take the settings as written.
let (_, ((rx0, rx1), rcol, _)) = compose_spread(false);
assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}");
}
const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8();
#[test]
@@ -7199,6 +7352,127 @@ fn cache_recomposes_when_the_baseline_grid_changes() {
assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline);
}
/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned
/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582).
fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) {
let mut d = Document::new(&NewDocument {
height: 618.0,
pages: 2,
facing_pages: false,
margins: designcraft_doc::Margins::uniform(36.0),
..Default::default()
});
Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0;
Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = relative_to;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let text = [LOREM; 12].join(" ");
let mut sids = Vec::new();
for (si, top) in [(0, 36.0), (1, 48.0)] {
let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).unwrap();
let st = d.story_mut(sid).unwrap();
st.paras[0].chars =
designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() };
sids.push(sid);
}
(d, [sids[0], sids[1]])
}
/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform).
fn page_baselines(d: &Document, sid: StoryId) -> Vec<f64> {
let cs = compose_story(d, sid, &ComposeOptions::default());
assert!(cs.is_overset());
cs.frames[0].lines.iter().map(|l| l.baseline).collect()
}
#[test]
fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() {
use designcraft_doc::GridRelative;
let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin);
// Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582.
let lines = page_baselines(&d, p1);
assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}");
assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}");
assert_eq!(lines.len(), 42);
// Page 2's own margin: lines at 48 + 13n.
let lines = page_baselines(&d, p2);
assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}");
// From the top of the page the lines are at 13n: the first at 52, and one line fewer.
let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let lines = page_baselines(&d, p1);
assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}");
assert_eq!(lines.len(), 41);
}
/// A group's transform moves a frame from the left page of a spread to the right one: the grid
/// starts at the right page's top margin.
#[test]
fn baseline_grid_follows_a_grouped_frame_to_its_page() {
use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape};
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap();
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
let frame = sp.items.remove(at);
let right = sp.pages[1].x;
let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default());
g.content = Content::Group { items: vec![frame] };
g.xf = designcraft_geom::Affine::translate((right, 0.0));
Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = cs.frames[0].lines[0].baseline;
assert!((first - 61.0).abs() < 1e-6, "{first}");
}
#[test]
fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() {
use designcraft_doc::GridRelative;
let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage);
let cache = Cache::new();
let a = cache.get(&d, p1, None);
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let b = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&a, &b));
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0;
let c = cache.get(&d, p1, None);
assert!(!Arc::ptr_eq(&b, &c));
assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0);
}
/// Pages trading places under a frame: the frame is on the other page, with its top margin.
#[test]
fn cache_recomposes_when_the_page_under_a_frame_changes() {
use designcraft_doc::GridRelative;
let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0;
d.settings.baseline_grid.start = 0.0;
d.settings.baseline_grid.increment = 13.0;
d.settings.baseline_grid.relative_to = GridRelative::TopMargin;
let lid = d.default_layer();
let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() };
let x = d.spreads[1].pages[0].x;
let (_, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(x + 36.0, 36.0, x + 336.0, 582.0), lid, LOREM, para).unwrap();
let cache = Cache::new();
let a = cache.get(&d, sid, None);
let sp = Arc::make_mut(&mut d.spreads[1]);
let (x0, x1) = (sp.pages[0].x, sp.pages[1].x);
(sp.pages[0].x, sp.pages[1].x) = (x1, x0);
let b = cache.get(&d, sid, None);
assert!(!Arc::ptr_eq(&a, &b));
assert_eq!(b.frames[0].lines[0].baseline, compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline);
assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0);
}
#[test]
fn cache_recomposes_when_a_wrap_layer_is_hidden() {
let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default());
@@ -7310,3 +7584,77 @@ fn rule_above_offset_is_measured_from_the_baseline() {
assert!((rule.height() - 1.5).abs() < 1e-6);
assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline);
}
#[test]
fn parent_page_frames_know_which_side_of_the_spine_they_are_on() {
// Folios on a facing-pages parent: towards/away from spine must differ between its left and right page.
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let w = d.parents[0].pages[0].width;
let para = ParaFormat { para: ParaAttrs { align: Some(Align::TowardsSpine), ..Default::default() }, ..Default::default() };
let (_, left_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(36.0, 700.0, w - 36.0, 730.0), lid, "7", para.clone()).unwrap();
let (_, right_story) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(w + 36.0, 700.0, 2.0 * w - 36.0, 730.0), lid, "8", para).unwrap();
assert!(frame_specs(&d, left_story)[0].left_page);
assert!(!frame_specs(&d, right_story)[0].left_page);
}
#[test]
fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() {
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
let lid = d.default_layer();
let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone());
let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0);
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap();
// The frame's own coordinates are on the left page; its group's transform puts it on the right one.
let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default());
g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0));
let sp = Arc::make_mut(&mut d.spreads[1]);
let at = sp.items.iter().position(|it| it.id == fid).unwrap();
g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] };
sp.items.push(Arc::new(g));
let spec = &frame_specs(&d, sid)[0];
assert!(spec.right_page && !spec.left_page);
assert_eq!(spec.page, Some(2));
}
/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on
/// the left it moved the first line the wrong way — out of the frame for the hanging indent a
/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where
/// the first line should have been (#1448).
#[test]
fn an_rtl_first_line_indent_is_at_the_right_edge() {
const MM: f64 = 72.0 / 25.4;
// Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the
// placement that is being measured, so no Arabic font has to be present.
let text = LOREM;
let rect = Rect::new(0.0, 0.0, 200.0, 300.0);
let para = |dir, align, first, left, right| ParaAttrs {
direction: Some(dir),
align: Some(align),
first_line_indent: Some(first),
left_indent: Some(left),
right_indent: Some(right),
..Default::default()
};
// The hanging indent of a numbered list, right to left.
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!(lines.len() >= 2, "the text wraps: {}", lines.len());
let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max);
let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min);
// The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it.
assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0]));
assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1]));
// And nothing leaves the frame on the left, which is where the first line used to go.
for (i, l) in lines.iter().enumerate() {
assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l));
}
// The same paragraph left to right is unchanged: the first line hangs on the left.
let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0]));
assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1]));
}
+38 -14
View File
@@ -93,6 +93,21 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize {
1
}
/// The per-line counts recovered from the split table, or `None` when they don't cover all `m`.
fn trace_counts(take: &[Vec<usize>], n: usize, m: usize) -> Option<Vec<usize>> {
let mut counts = Vec::with_capacity(n);
let mut s = 0_usize;
for lines in (1..=n).rev() {
let t = take[lines][s];
if t == 0 || s + t > m {
return None;
}
counts.push(t);
s += t;
}
(s == m).then_some(counts)
}
/// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their
/// break minimums. One line when no split satisfies them.
fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize> {
@@ -142,17 +157,7 @@ fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec<usize>
if !best[n][0].is_finite() {
return vec![m];
}
let mut counts = Vec::with_capacity(n);
let mut s = 0_usize;
for lines in (1..=n).rev() {
let t = take[lines][s];
if t == 0 || s + t > m {
return vec![m];
}
counts.push(t);
s += t;
}
if s != m { vec![m] } else { counts }
trace_counts(&take, n, m).unwrap_or_else(|| vec![m])
}
fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) {
@@ -217,13 +222,31 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
restore(line, members, &saved);
return 0.0;
}
let rows = counts.len().max(1);
let small = st.size * scale;
// 0 is the default (one small em). Any other value, including a negative one, is added to
// that em and then clamped so the rows cannot swap order.
// Bounded, so a hostile value can't move a row to an infinite or huge coordinate.
let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 };
let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) };
position_warichu_rows(line, members, &counts, &widths, RowLayout { block, x0: old_x0, gap }, st);
let delta = block - old_span;
shift_non_member_glyphs(line, members, &old_xs, old_x0, old_right, delta, scale);
delta
}
#[derive(Clone, Copy)]
struct RowLayout {
block: f64,
x0: f64,
gap: f64,
}
fn position_warichu_rows(line: &mut [PlacedGlyph], members: &[usize], counts: &[usize], widths: &[f64], layout: RowLayout, st: &RunStyle) {
let block = layout.block;
let old_x0 = layout.x0;
let gap = layout.gap;
let rows = counts.len().max(1);
let mut off = 0_usize;
for (li, &c) in counts.iter().enumerate() {
let w = widths[li];
@@ -242,7 +265,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
}
off += c;
}
let delta = block - old_span;
}
fn shift_non_member_glyphs(line: &mut [PlacedGlyph], members: &[usize], old_xs: &[f64], old_x0: f64, old_right: f64, delta: f64, scale: f64) {
let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect();
placed.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut member_at = vec![false; line.len()];
@@ -265,7 +290,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f
g.x = anchor.1 + (g.x - anchor.0) * scale;
}
}
delta
}
#[cfg(test)]
+5
View File
@@ -60,6 +60,11 @@ pub enum AnchorPosition {
/// Keep Within Top/Bottom Column Boundaries.
#[serde(default)]
keep_within_column: bool,
/// Relative to Spine: the settings describe a left-hand page, and on a right-hand page
/// the reference point, the object point and the X offset mirror horizontally, so an
/// object in the outside margin stays in the outside margin.
#[serde(default)]
spine_relative: bool,
},
}
+4 -1
View File
@@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option<String> {
/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`).
fn intern_tag(tag: String) -> Option<&'static str> {
const MAX_TAGS: usize = 512;
static TAGS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
static TAGS: std::sync::Mutex<Vec<&str>> = std::sync::Mutex::new(Vec::new());
let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(t) = tags.iter().find(|t| **t == tag) {
return Some(t);
@@ -1270,6 +1270,9 @@ attr_set! {
number_expression: String = "^#.^t".into(),
/// Text after the bullet (metacharacters expand as in [`expand_list_text`]).
list_separator: String = "\t".into(),
/// The hanging indent was set by the Bulleted or Numbered List button, which takes it away
/// again with the list (an indent the paragraph had before stays).
list_hang: bool = false,
balance_ragged: bool = false,
optical_margin: bool = false,
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
+20 -15
View File
@@ -100,6 +100,25 @@ fn sort_key(s: &str) -> String {
if k.is_empty() { s.to_lowercase() } else { k }
}
/// Record one reference's see-target or page on its topic node.
fn record_ref(n: &mut Node, res: &Resolved) {
match &res.r.range {
IndexRange::See(t) => n.see.push(t.clone()),
IndexRange::SeeAlso(t) => {
n.see_also.push(t.clone());
if let Some(p) = res.pages {
n.pages.push(p);
}
}
IndexRange::SuppressPageRange => {}
_ => {
if let Some(p) = res.pages {
n.pages.push(p);
}
}
}
}
/// The index as paragraphs `(style, text)`; `page_name` formats an absolute page.
pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> String) -> Vec<(String, String)> {
let mut root: BTreeMap<String, Node> = BTreeMap::new();
@@ -114,21 +133,7 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S
.map(|(i, t)| (format!("{}\u{0}{}", sort_key(res.r.sort.get(i).filter(|s| !s.is_empty()).unwrap_or(t)), t.trim()), t.trim().to_string()))
.collect();
let n = descend(&mut root, &keys);
match &res.r.range {
IndexRange::See(t) => n.see.push(t.clone()),
IndexRange::SeeAlso(t) => {
n.see_also.push(t.clone());
if let Some(p) = res.pages {
n.pages.push(p);
}
}
IndexRange::SuppressPageRange => {}
_ => {
if let Some(p) = res.pages {
n.pages.push(p);
}
}
}
record_ref(n, res);
}
let mut out = Vec::new();
if !spec.title.is_empty() {
+31 -1
View File
@@ -452,12 +452,34 @@ pub struct TextFrameOptions {
pub column_rule_bottom_inset: f64,
/// Use a custom baseline grid for this frame.
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_grid: Option<(f64, f64)>,
pub baseline_grid: Option<FrameBaselineGrid>,
/// Type on a Path: the text runs along the item's path instead of filling it.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathType>,
}
/// A text frame's custom baseline grid. Saved as `[start, increment]`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "(f64, f64)", into = "(f64, f64)")]
pub struct FrameBaselineGrid {
/// The first grid line, down from the top of the page.
pub start: f64,
/// The distance between grid lines; 0 or less turns the grid off.
pub increment: f64,
}
impl From<(f64, f64)> for FrameBaselineGrid {
fn from((start, increment): (f64, f64)) -> Self {
FrameBaselineGrid { start, increment }
}
}
impl From<FrameBaselineGrid> for (f64, f64) {
fn from(g: FrameBaselineGrid) -> Self {
(g.start, g.increment)
}
}
/// Type on a Path Options.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -1662,6 +1684,14 @@ mod tests {
assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default()));
}
#[test]
fn frame_baseline_grid_saves_as_start_then_increment() {
let v = serde_json::json!({"baselineGrid": [6.0, 14.0]});
let o = serde_json::from_value::<TextFrameOptions>(v).unwrap();
assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0 }));
assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0]));
}
#[test]
fn bounds_and_text_area() {
let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)));
+11
View File
@@ -147,6 +147,17 @@ impl Default for BaselineGrid {
}
}
impl BaselineGrid {
/// The first grid line on `page`, measured down from the page top: `start` from the top of
/// the page or from its top margin. The other lines follow every `increment` below it.
pub fn origin(&self, page: &page::Page) -> f64 {
match self.relative_to {
GridRelative::TopOfPage => self.start,
GridRelative::TopMargin => self.start + page.margins.top,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DocumentGrid {
+1 -1
View File
@@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str {
pub fn set_figures(list: &mut Vec<String>, id: &str) -> bool {
let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false };
list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum"));
list.extend(tags.iter().map(|t| t.to_string()));
list.extend(tags.iter().map(ToString::to_string));
true
}
+9 -3
View File
@@ -1040,6 +1040,15 @@ impl Table {
if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) {
return Err(format!("table {}: header/footer rows out of order", self.id));
}
self.check_spans()?;
for cell in &self.cells {
cell.text.check()?;
}
Ok(())
}
/// Every merged cell stays inside the grid and no two merged cells overlap.
fn check_spans(&self) -> Result<(), String> {
let mut seen = vec![false; self.cells.len()];
for r in 0..self.nrows() {
for c in 0..self.ncols() {
@@ -1064,9 +1073,6 @@ impl Table {
}
}
}
for cell in &self.cells {
cell.text.check()?;
}
Ok(())
}
}
+23 -8
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Anchored Object…",
["Object", "Anchored Object"],
None,
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text",
has_doc,
insert
),
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Anchored Object Options…",
["Object", "Anchored Object"],
None,
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}",
has_doc,
options
),
@@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
("custom", b) => {
use designcraft_doc::anchored::AnchorRelative as R;
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
}
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b {
AnchorPosition::Custom {
x_relative,
y_relative,
x_offset,
y_offset,
object_point,
ref_point,
keep_within_column,
spine_relative,
} => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative),
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false),
};
let rel = |k: &str, d: R| -> Result<R> {
match p.get(k) {
@@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
object_point: pt("objectPoint", op0),
ref_point: pt("refPoint", rp0),
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0),
}
}
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
@@ -109,7 +117,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
fn check_item(it: &Item) -> Result<()> {
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
@@ -316,5 +324,12 @@ mod custom_tests {
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
"IDML Anchored position"
);
// Relative to Spine keeps the other custom settings.
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert!(matches!(
st.objects[0].position,
designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0
));
}
}
+1 -1
View File
@@ -130,7 +130,7 @@ impl ChapterBatch {
fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> {
use std::path::Path;
if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) {
return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first")));
}
let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+1 -1
View File
@@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> {
continue;
}
}
i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
}
out
}
+1 -1
View File
@@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> {
i = end;
continue;
}
i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1));
i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1));
}
out
}
+1 -1
View File
@@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter {
}
pub fn extension(name: &str) -> String {
Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
struct HeaderDelimiterCandidate {
+120 -6
View File
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
// Items copied last (no text copied since) paste into the text as anchored objects.
if formatted
&& clip.is_none()
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) {
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
return s.edit(|d, sel| {
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
st.delete(r.start.min(st.len())..r.end.min(st.len()));
let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?;
// Fresh ids, story and asset copies for every paste, as on the page.
let mut stories = std::collections::HashMap::new();
let fresh: Vec<designcraft_doc::Item> = list
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?;
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "anchored": list.len()}))
});
@@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
@@ -220,12 +262,29 @@ pub fn specs() -> Vec<CommandSpec> {
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
// A key object needs another object to align to it.
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
st.selection.key = None;
}
} else {
st.selection.items.push(id);
}
st.bump_revision();
ok()
}),
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
let st = s.doc_mut()?;
if !st.selection.items.contains(&id) {
return Err(super::bad("selection.key", "the key object must be selected"));
}
if st.selection.items.len() < 2 {
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
}
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
st.bump_revision();
ok()
}),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
@@ -327,7 +386,8 @@ pub fn specs() -> Vec<CommandSpec> {
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
if let Some(text) = text {
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
@@ -538,6 +598,60 @@ mod text_clipboard_tests {
s.doc().unwrap().doc.check().unwrap();
}
/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212).
#[test]
fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let story = frame(&mut s, "ab");
let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let marker = copied_objects_text(&s);
s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap();
let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap();
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere");
// What the object copy put on the system clipboard still pastes the objects.
assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1);
// With no insertion point too: the rectangle, not a text frame holding the marker.
s.execute("edit.deselectAll", &json!({})).unwrap();
let r = s.execute("edit.paste", &json!({"text": marker})).unwrap();
let id = ItemId(r["ids"][0].as_u64().unwrap());
assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none());
s.doc().unwrap().doc.check().unwrap();
// Paste without Formatting inserts the text given; empty text is no text.
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap();
assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err());
}
/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text.
#[test]
fn copied_text_frames_paste_at_a_caret_as_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let target = frame(&mut s, "ab");
let copy_and_paste = |s: &mut Session, ids: Value, text: Value| {
s.execute("selection.set", &json!({"ids": ids})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("edit.paste", &json!({"text": text})).unwrap()
};
let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone();
// The same text copied in another app, and the system clipboard read back as absent.
let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1"));
assert!(r.get("anchored").is_none(), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab");
// A group with a text frame inside, its own placeholder on the system clipboard.
let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone();
let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone();
let r = copy_and_paste(&mut s, json!([group]), Value::Null);
assert!(r.get("anchored").is_none(), "{r}");
assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab");
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn outside_text_never_pastes_markers() {
let mut s = Session::new();
+61 -2
View File
@@ -10,7 +10,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})",
has_doc, export_pdf),
cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None,
"{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded",
@@ -331,7 +331,12 @@ fn pages_param(id: &str, p: &Value, page_count: usize) -> Result<Option<Vec<usiz
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n >= 1 && *n <= page_count)
.ok_or_else(|| bad(id, format!("bad page {x}")))?;
v.push(n - 1);
// A page named twice is one page, as `"2,2"` already is: the list says which
// pages to export, not how many times. `[2, 2]` exported two copies of page 2
// while `"2,2"` exported one.
if !v.contains(&(n - 1)) {
v.push(n - 1);
}
}
Some(v)
}
@@ -371,6 +376,10 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
None => Standard::None,
};
let created = match p.get("created") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?),
};
Ok(PdfOptions {
pages,
spreads,
@@ -382,6 +391,7 @@ pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
author: str_param(p, "author").map(str::to_string),
tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false),
media: p.get("media").and_then(Value::as_bool).unwrap_or(false),
created,
..PdfOptions::default()
})
}
@@ -1326,3 +1336,52 @@ mod pptx_tests {
assert!(x.contains("<p:pic>") && !x.contains("prst=\"ellipse\""), "{x}");
}
}
#[cfg(test)]
mod page_list_tests {
use serde_json::{Value, json};
use crate::Session;
fn doc(pages: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap();
s
}
/// The same selection written two ways exported different files: `"2,2"` is one page, because
/// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2.
/// The list says which pages to export, not how many times.
#[test]
fn a_page_named_twice_is_exported_once_either_way() {
let mut s = doc(4);
for (pages, want) in [
(json!("2,2"), 1),
(json!([2, 2]), 1),
(json!([2, 2, 2]), 1),
(json!("2-2,2"), 1),
(json!([1, 2, 1]), 2),
(json!("1,2,1"), 2),
(json!([4, 1]), 2),
(json!("4,1"), 2),
(json!("all"), 4),
] {
let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}");
let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap();
assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}");
}
// The two forms now agree byte for byte, and the pages come out in the document's order
// whichever order the list names them in (which is how the export already behaved).
let mut file = |pages: Value| {
let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap();
r["base64"].as_str().unwrap_or_default().to_string()
};
assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree");
assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order");
assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page");
// A page that does not exist is still refused, and an empty list still exports nothing.
assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err());
assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err());
}
}
+54 -5
View File
@@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result<Vec<u8>> {
/// the file opens from the file manager. A path without a file name is left for the write to fail.
pub fn with_document_extension(path: &str) -> String {
let p = std::path::Path::new(path);
let ext = p.extension().and_then(|e| e.to_str());
let ext = p.extension().and_then(std::ffi::OsStr::to_str);
if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) {
return path.to_string();
}
@@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
Ok(nd)
}
/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a
/// paragraph's last word or across a column. Opened and imported files keep their own values,
/// which is why `ParaProps` keeps the IDML defaults (on).
fn new_document(nd: &NewDocument) -> Document {
let mut d = Document::new(nd);
if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) {
st.para.hyph_capitalized = Some(false);
st.para.hyph_last_word = Some(false);
st.para.hyph_across_column = Some(false);
}
d
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let mut nd = new_document_settings(s, p)?;
s.untitled += 1;
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
let d = Document::new(&nd);
let d = new_document(&nd);
let i = s.add_document(DocState::new(d, None));
Ok(json!({"index": i}))
}
@@ -259,7 +272,7 @@ impl Session {
self.untitled += 1;
}
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
let st = DocState::new(Document::new(&nd), None);
let st = DocState::new(new_document(&nd), None);
let uid = st.uid;
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
Some((i, d)) => {
@@ -469,7 +482,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
// The content decides first: a file's extension may be missing or wrong. The extension
// then routes files whose content isn't recognised to the reader that explains why.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase();
let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{");
if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) {
return super::interchange::open_idml(s, p);
@@ -679,7 +692,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.active()?;
st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_))))
});
let spread = super::spread_param(p, "file.place")?;
// `"spread": null` counts as omitted, like the other placement parameters.
let spread = match p.get("spread") {
Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0),
_ => super::spread_param(p, "file.place")?,
};
let lid = s.doc()?.active_layer;
let px = p.get("x").and_then(Value::as_f64);
let py = p.get("y").and_then(Value::as_f64);
@@ -2120,3 +2137,35 @@ mod recent_tests {
assert!(old.recent_files.is_empty(), "older prefs.json without the list still load");
}
}
#[cfg(test)]
mod hyphenation_default_tests {
use std::sync::Arc;
use designcraft_doc::BASIC_PARAGRAPH;
use designcraft_doc::build::NewDocument;
use serde_json::json;
use crate::Session;
fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) {
let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default());
(pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column)
}
#[test]
fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false));
let mut d = designcraft_doc::Document::new(&NewDocument::default());
let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap();
st.para.hyph_capitalized = Some(true);
st.para.hyph_last_word = Some(true);
st.para.hyph_across_column = Some(true);
let b64 = super::base64_encode(&designcraft_idml::export_idml(&d));
s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap();
assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true));
}
}
+90 -35
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Weak};
use designcraft_compose::grep::{Grep, GrepOptions, translate_replacement};
use designcraft_doc::story::floor_char_boundary;
use designcraft_doc::{Document, Selection, StoryId, TextSel};
use designcraft_doc::{CellAddr, Document, Selection, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, has_doc, str_param};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(query "find.find", "Find", ["Edit", "Find/Change"], None,
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text}]",
"{find, grep?: bool, caseSensitive?: bool, wholeWord?: bool, scope?: document|story|selection, story?, change?|first?|attrs?: ignored (find.change takes them; one request serves all three Find/Change commands)} → matches [{story, start, end, text, cell?: {table, row, col}}] (document scope also searches table cells; start/end then index the cell's text)",
has_doc, find),
cmd!(
"find.change",
@@ -153,6 +153,9 @@ impl Matcher {
}
}
/// A searched text: a story, or one table cell of it, and the byte range inside.
type Target = (StoryId, Option<CellAddr>, Range<usize>);
/// A search owns its original domain, not the selection used to paint the latest hit.
/// Weak identity avoids retaining an old document merely for this transient cache.
#[derive(Clone)]
@@ -163,7 +166,7 @@ pub(crate) struct FindContext {
selection: Selection,
domain: Domain,
matcher: Matcher,
targets: Vec<(StoryId, Range<usize>)>,
targets: Vec<Target>,
order: Vec<usize>,
target: usize,
offset: usize,
@@ -184,21 +187,25 @@ impl FindContext {
self.target = 0;
self.last_match_end = None;
self.order = (0..self.targets.len()).collect();
let caret = st.selection.text.filter(|t| t.cell.is_none() && st.selection.cells.is_none());
if self.domain == Domain::Document
&& let Some(t) = st.selection.text
&& let Some(i) = self.targets.iter().position(|(sid, _)| *sid == t.story)
{
let caret = st.selection.text.filter(|_| st.selection.cells.is_none());
// A caret in a searched cell starts there; one in a footnote starts at its story.
let start = st.selection.text.and_then(|t| {
let at = |want: Option<CellAddr>| self.targets.iter().position(|(sid, cell, _)| *sid == t.story && *cell == want);
at(t.cell).or_else(|| at(None))
});
if let Some(i) = start.filter(|_| self.domain == Domain::Document) {
self.order.rotate_left(i);
}
let first = self.order.first().and_then(|i| self.targets.get(*i));
self.offset = match first {
Some((sid, r)) if self.domain != Domain::Selection => caret.filter(|t| t.story == *sid).map_or(r.start, |t| t.range().end.min(r.end)),
Some((_, r)) => r.start,
Some((sid, cell, r)) if self.domain != Domain::Selection => {
caret.filter(|t| t.story == *sid && t.cell == *cell).map_or(r.start, |t| t.range().end.min(r.end))
}
Some((_, _, r)) => r.start,
None => 0,
};
if let Some((sid, r)) = first {
let text = target_text(&st.doc, *sid, r)?;
if let Some((sid, cell, r)) = first {
let text = target_text(&st.doc, *sid, *cell, r)?;
if text.get(self.offset.saturating_sub(r.start)..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
}
@@ -209,7 +216,7 @@ impl FindContext {
fn advance_target(&mut self) {
self.target = self.target.saturating_add(1);
self.last_match_end = None;
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, r)| r.start);
self.offset = self.order.get(self.target).and_then(|i| self.targets.get(*i)).map_or(0, |(_, _, r)| r.start);
}
fn advance_empty(&mut self, text: &str, relative_end: usize, end: usize) {
@@ -222,8 +229,8 @@ impl FindContext {
}
}
fn target_text<'a>(d: &'a Document, sid: StoryId, r: &Range<usize>) -> Result<&'a str> {
d.story(sid)
fn target_text<'a>(d: &'a Document, sid: StoryId, cell: Option<CellAddr>, r: &Range<usize>) -> Result<&'a str> {
d.text_story(sid, cell)
.ok_or_else(|| bad("find", "no such story"))?
.text
.get(r.clone())
@@ -246,7 +253,7 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let targets = match domain {
Domain::Explicit(sid) => {
let len = d.story(sid).ok_or_else(|| bad("find", "no such story"))?.len();
vec![(sid, 0..len)]
vec![(sid, None, 0..len)]
}
Domain::Story | Domain::Selection => {
if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) {
@@ -255,10 +262,21 @@ fn scope(s: &Session, p: &Value) -> Result<FindContext> {
let t = st.selection.text.ok_or_else(|| bad("find", "no text selected"))?;
let story = d.story(t.story).ok_or_else(|| bad("find", "no such story"))?;
let r = if domain == Domain::Selection { t.range() } else { 0..story.len() };
target_text(d, t.story, &r)?;
vec![(t.story, r)]
target_text(d, t.story, None, &r)?;
vec![(t.story, None, r)]
}
Domain::Document => d.stories.values().map(|x| (x.id, 0..x.len())).collect(),
// Table cells hold their own text outside `stories`; search them right after the story that anchors the table.
Domain::Document => d
.stories
.values()
.flat_map(|x| {
let cells = x.tables.iter().flat_map(move |(id, t)| {
let cols = t.ncols().max(1);
t.cells.iter().enumerate().map(move |(i, c)| (x.id, Some(CellAddr { table: *id, row: i / cols, col: i % cols }), 0..c.text.len()))
});
std::iter::once((x.id, None, 0..x.len())).chain(cells)
})
.collect(),
};
let mut context = FindContext {
document: Arc::downgrade(d),
@@ -285,10 +303,14 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let context = scope(s, p)?;
let st = s.doc()?;
let mut out = Vec::new();
for (sid, r) in &context.targets {
let text = target_text(&st.doc, *sid, r)?;
for (sid, cell, r) in &context.targets {
let text = target_text(&st.doc, *sid, *cell, r)?;
for m in re.find_all(text, MAX_MATCHES.saturating_sub(out.len())).map_err(|e| bad("find", e))? {
out.push(json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)}));
let mut hit = json!({"story": sid.0, "start": r.start + m.start, "end": r.start + m.end, "text": text.get(m)});
if let Some(c) = cell {
hit["cell"] = json!({"table": c.table, "row": c.row, "col": c.col});
}
out.push(hit);
}
}
s.find_context = Some(context);
@@ -317,8 +339,8 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
let targets = scope(s, p)?.targets;
s.edit(|d, sel| {
let mut count = 0usize;
for (sid, r) in targets {
let Some(story) = d.story(sid) else { continue };
for (sid, cell, r) in targets {
let Some(story) = d.text_story(sid, cell) else { continue };
// Collect matches first (positions refer to the original text), apply back to front.
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
let edits: Vec<(std::ops::Range<usize>, String)> = if grep {
@@ -330,7 +352,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
let Some(st) = d.story_mut(sid) else { continue };
let Some(st) = d.text_story_mut(sid, cell) else { continue };
for (range, new) in edits.iter().rev() {
st.replace(range.clone(), new);
if !over.is_empty() && !new.is_empty() {
@@ -338,7 +360,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
}
count += 1;
}
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) {
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) {
// Repair byte validity once against the final story, without remapping valid offsets.
let range = t.range();
let start = floor_char_boundary(&st.text, range.start);
@@ -365,8 +387,8 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
let mut context = scope(s, p)?;
let st = s.doc()?;
let mut hit = None;
while let Some((sid, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, &r)?;
while let Some((sid, cell, r)) = context.order.get(context.target).and_then(|i| context.targets.get(*i)).cloned() {
let text = target_text(&st.doc, sid, cell, &r)?;
let from = context.offset.checked_sub(r.start).ok_or_else(|| bad("find", "invalid search cursor"))?;
if text.get(from..).is_none() {
return Err(bad("find", "invalid UTF-8 search cursor"));
@@ -379,7 +401,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
context.advance_empty(text, m.end, b);
continue;
}
hit = Some((sid, a, b));
hit = Some((sid, cell, a, b));
context.last_match_end = Some(b);
if m.is_empty() {
context.advance_empty(text, m.end, b);
@@ -390,17 +412,22 @@ fn find_next(s: &mut Session, p: &Value) -> Result<Value> {
}
context.advance_target();
}
let Some((sid, a, b)) = hit else {
let Some((sid, cell, a, b)) = hit else {
s.find_context = Some(context);
return Ok(Value::Null);
};
let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied());
let cell_json = cell.map(|c| json!({"table": c.table, "row": c.row, "col": c.col}));
let stm = s.doc_mut()?;
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None });
stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell });
stm.bump_revision();
context.acknowledge(stm);
s.find_context = Some(context);
Ok(json!({"story": sid.0, "start": a, "end": b}))
let mut out = json!({"story": sid.0, "start": a, "end": b});
if let Some(c) = cell_json {
out["cell"] = c;
}
Ok(out)
}
#[cfg(test)]
@@ -1007,6 +1034,31 @@ mod tests {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
#[test]
fn document_scope_searches_and_changes_table_cells() {
let (mut s, sid) = scope_session("Ore 12.15 here");
scope_select(&mut s, sid, 0, 0);
let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap();
// The caret sits in the first cell, so Find Next goes to the second cell, then the story.
let p = json!({"find": "Ore 12.15", "scope": "document"});
let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 };
assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}}));
assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell))));
let next = s.execute("find.next", &p).unwrap();
assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}");
let hits = s.execute("find.find", &p).unwrap();
let hits = hits.as_array().unwrap();
assert_eq!(hits.len(), 2);
let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect();
assert_eq!(in_cell.len(), 1);
assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1)));
let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap();
assert_eq!(n["count"], 2);
assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty());
assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2);
}
fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) {
let (mut s, sid) = scope_session("red");
scope_select(&mut s, sid, 3, 3);
@@ -1051,12 +1103,15 @@ mod tests {
p["story"] = json!(sid.0);
p["scope"] = json!("selection");
}
// Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level.
let in_cell = if explicit || note { 0 } else { child.matches("red").count() };
let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() };
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1);
assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell);
scope_hit(&mut s, &p, sid, 0, 3);
s.doc_mut().unwrap().selection = selection.clone();
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, child);
assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell);
assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed);
assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets");
p["find"] = json!("blue");
p["change"] = json!("red");
+35 -30
View File
@@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) {
}
}
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
fn detect(bounds: &[Rect]) -> FlexLayout {
let tol = 0.5;
let in_line = |row: bool| {
let mut v: Vec<&Rect> = bounds.iter().collect();
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
};
fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option<f64> {
let mut v: Vec<&Rect> = bounds.iter().collect();
v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) });
let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol });
let gaps: Vec<f64> = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect();
(apart && bounds.len() > 1).then(|| gaps.iter().sum::<f64>() / gaps.len().max(1) as f64)
}
fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign {
let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol);
let align = |row: bool| {
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
if same(a) {
FlexAlign::Start
} else if same(c) {
FlexAlign::Center
} else if same(e) {
FlexAlign::End
} else {
FlexAlign::Start
}
};
let auto = |gap: f64, row: bool| FlexLayout {
let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) =
if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) };
if same(a) {
FlexAlign::Start
} else if same(c) {
FlexAlign::Center
} else if same(e) {
FlexAlign::End
} else {
FlexAlign::Start
}
}
fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout {
FlexLayout {
direction: if row { FlexDirection::Row } else { FlexDirection::Column },
justify: FlexJustify::SpaceBetween,
align: align(row),
align,
width: FlexSize::Auto,
height: FlexSize::Auto,
gap_h: if row { gap.max(0.0) } else { 0.0 },
gap_v: if row { 0.0 } else { gap.max(0.0) },
..Default::default()
};
match (in_line(true), in_line(false)) {
(Some(gap), _) => auto(gap, true),
(None, Some(gap)) => auto(gap, false),
}
}
/// InDesign's detection when objects become a container: objects in a row (or column) keep it,
/// with Auto size and Space Between at their own spacing; others wrap in a row of their width.
fn detect(bounds: &[Rect]) -> FlexLayout {
let tol = 0.5;
match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) {
(Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)),
(None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)),
_ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() },
}
}
+6 -3
View File
@@ -72,7 +72,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
"currentPage" => IndexRange::CurrentPage,
"toEndOfStory" => IndexRange::ToEndOfStory,
"suppressPageRange" => IndexRange::SuppressPageRange,
"nextParagraphs" => IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).unwrap_or(1) as u32),
"nextParagraphs" => {
IndexRange::NextParagraphs(p.get("paragraphs").and_then(Value::as_u64).map_or(1, |x| u32::try_from(x).unwrap_or(u32::MAX)))
}
"see" => IndexRange::See(target()),
"seeAlso" => IndexRange::SeeAlso(target()),
r => return Err(bad("index.addReference", format!("unknown range `{r}`"))),
@@ -103,8 +105,9 @@ fn resolve(s: &Session, d: &Document, skip: Option<StoryId>) -> Vec<(StoryId, us
IndexRange::ToEndOfStory => page(st.len().saturating_sub(1)),
IndexRange::NextParagraphs(n) => {
let ranges = st.para_ranges();
let pi = (st.para_at(pos) + *n as usize).min(ranges.len() - 1);
page(ranges[pi].end.saturating_sub(1).max(ranges[pi].start))
let n = usize::try_from(*n).unwrap_or(usize::MAX);
let pi = st.para_at(pos).saturating_add(n).min(ranges.len().saturating_sub(1));
ranges.get(pi).and_then(|r| page(r.end.saturating_sub(1).max(r.start)))
}
_ => start,
};
+45 -3
View File
@@ -161,14 +161,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Insert Pages…",
["Layout", "Pages"],
Some("Cmd+Shift+P"),
"{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}",
"{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}",
has_doc,
|s, p| {
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize;
const ID: &str = "layout.pages.insert";
// A count that is given but is not a number of pages used to insert one page:
// `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped.
let count = match p.get("count").filter(|v| !v.is_null()) {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) {
Some(n) => n,
None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))),
},
};
let st = s.doc()?;
let n = st.doc.page_count();
// An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end`
// pins deliberately.
let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1));
let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id));
s.edit(|d, _| {
let ids = d.insert_pages(Some(after), count, parent)?;
Ok(json!({"pages": ids.len(), "total": d.page_count()}))
@@ -2497,3 +2508,34 @@ mod parent_base_tests {
#[cfg(test)]
#[path = "parent_delete_tests.rs"]
mod parent_delete_tests;
#[cfg(test)]
mod insert_count_tests {
use serde_json::json;
use crate::Session;
/// A count that is given but is not a number of pages used to insert one page anyway:
/// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped
/// to 9999. A position past the last page appends, which is deliberate.
#[test]
fn a_page_count_insert_pages_cannot_mean_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap();
assert_eq!(total(&mut s), 3, "one page added");
for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] {
let e = s.execute("layout.pages.insert", &params).expect_err("refused").to_string();
assert!(e.contains("1 to 9999 pages"), "{params}: {e}");
}
// Nothing was inserted by any of those.
assert_eq!(s.doc().unwrap().doc.page_count(), 3);
// A position past the last page still appends, which `insert_pages_bounds.rs` pins.
let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap();
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4)));
// The positions it does have still work: after the first page of three.
let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap();
assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6)));
}
}
+14 -4
View File
@@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool
let Some(link) = &a.link else { return false };
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
!crate::io::document_path(link, document).is_ok_and(crate::io::exists)
}
#[cfg(target_arch = "wasm32")]
{
@@ -480,7 +480,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into());
let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into());
if let Some(e) = &ext {
name = std::path::Path::new(&name).with_extension(e).into_os_string();
}
@@ -490,8 +490,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
Some(dir) => Some(dir.clone()),
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
};
let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\']));
// A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`,
// a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b`
// on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name
// itself, directly in the folder as the file system resolves it, counts. The link keeps
// the folder as given.
let cand = folder.zip(plain_name).and_then(|(f, n)| {
let c = crate::io::folder_path(n, &f).ok()?;
let real = |p: &Path| crate::io::canonicalize(p).ok();
let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p));
(c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c)
});
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
todo.push((a.id, cand));
} else {
+265 -2
View File
@@ -1,13 +1,37 @@
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
use designcraft_doc::NumberedList;
use designcraft_doc::{ListType, NumberedList};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
use crate::{Result, Session};
/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
/// the text after its tab at the left indent.
pub const LIST_HANG: f64 = 18.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"list.bullets",
"Apply Bullets",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Bullets)
),
cmd!(
"list.numbers",
"Apply Numbers",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Numbers)
),
cmd!(
"list.define",
"Define Lists…",
@@ -48,12 +72,181 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
/// style rather than set locally, so a paragraph back to its style shows no override.
fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
let on = match p.get("on") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
};
let targets = super::text::format_targets_pub(s);
if targets.is_empty() {
return Err(bad(name, "select text or a text frame"));
}
// Off when every selected paragraph already has this list, as the button shows it pressed.
let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)));
s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
let span = para_span(&st.text, &t.range);
st.format_paras(span, |pf| {
let (now, _) = styles.resolve_para(pf);
// Off takes away this list only: a paragraph with the other list keeps it.
if !on && now.list_type != kind {
return;
}
let (style, _) = styles.resolve_para_style(&pf.style);
// The hanging indent this button set (and nothing has changed since).
let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG;
// A value: left to the style when it gives the same, else set here.
let mut set = |key: &str, v: Value, same: bool| {
let _ = pf.para.set_json(key, if same { &Value::Null } else { &v });
};
let list = if on { kind } else { ListType::None };
set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 {
set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG);
set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG);
set("listHang", json!(true), false);
} else if !on && now.list_type == kind {
// Only the hanging indent set here: one the paragraph had before, or its style
// gives, stays.
if own_hang {
set("leftIndent", json!(0.0), style.left_indent == 0.0);
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
}
set("listHang", Value::Null, true);
}
});
}
Ok(json!({"on": on}))
})
}
/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after
/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final
/// return, stays in when the range runs to the end (a whole frame).
fn para_span(text: &str, range: &std::ops::Range<usize>) -> std::ops::Range<usize> {
let end = range.end.max(range.start);
if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end }
}
/// Whether every paragraph of `targets` has list `kind`.
fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool {
targets.iter().all(|t| {
d.text_story(t.story, t.cell).is_some_and(|st| {
let span = para_span(&st.text, &t.range);
let (a, b) = (st.para_at(span.start), st.para_at(span.end));
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
})
})
}
impl Session {
/// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows
/// pressed: every paragraph the list commands apply to has list `kind`, through the selected
/// text, table cells or text frames.
pub fn selection_has_list(&self, kind: ListType) -> bool {
let targets = super::text::format_targets_pub(self);
!targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
/// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
/// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
#[test]
fn list_buttons_apply_the_default_hanging_indent() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
let para = |s: &Session, i: usize| {
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
(d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
};
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
for i in 0..2 {
let (p, _) = para(&s, i);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
}
assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The number of a bullet's tab: the text starts at the left indent.
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let line = &cs.frames[0].lines[0];
let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
// Numbers instead: the indent stays.
s.execute("list.numbers", &json!({})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
// Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
let (p, own) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
// A paragraph with its own indents keeps them.
s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
s.execute("list.bullets", &json!({"on": false})).unwrap();
assert_eq!(para(&s, 0).0.left_indent, 36.0);
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
}
/// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted
/// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own
/// list only: a bulleted paragraph keeps bullets and indent when Numbers go off.
#[test]
fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
let select = |s: &mut Session, a: usize, b: usize| {
s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap();
};
select(&mut s, 0, 3);
s.execute("list.bullets", &json!({})).unwrap();
assert!(s.selection_has_list(ListType::Bullets));
assert!(!s.selection_has_list(ListType::Numbers));
// From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both.
select(&mut s, 0, 7);
assert!(!s.selection_has_list(ListType::Bullets));
// The frame selected: its whole story, Flour unbulleted.
s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
// Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent.
select(&mut s, 5, 7);
s.execute("list.numbers", &json!({"on": true})).unwrap();
select(&mut s, 0, 7);
s.execute("list.numbers", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0;
assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0));
assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0));
// With no text or frame selected, nothing is pressed.
s.execute("selection.set", &json!({"ids": []})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
}
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
@@ -99,4 +292,74 @@ mod tests {
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '4');
}
/// A selection ending right after a return (the whole first paragraph, as a triple click selects
/// it) applies the list to that paragraph only, and the button shows it pressed.
#[test]
fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() {
use designcraft_doc::ListType;
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s
.execute(
"frame.create",
&json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs
Milk
Flour
"}),
)
.unwrap();
let sid = f["story"].as_u64().unwrap();
let kinds = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::<Vec<_>>()
};
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
assert!(!s.selection_has_list(ListType::Bullets));
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]);
assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected");
// The whole frame still takes in its last, empty paragraph.
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap();
s.execute("list.numbers", &json!({"on": true})).unwrap();
assert_eq!(kinds(&s), [ListType::Numbers; 4]);
}
/// A paragraph's own hanging indent, the same as the button's, stays when the list goes off:
/// the button takes away only the indent it set.
#[test]
fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para.list_hang, None, "nothing of the list left");
}
/// A hanging indent the paragraph style gives stays when the list goes off.
#[test]
fn a_style_hanging_indent_stays_when_the_list_goes_off() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap();
s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
s.execute("list.bullets", &json!({"on": false})).unwrap();
let d = &s.doc().unwrap().doc;
let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0];
let p = d.styles.resolve_para(pf).0;
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0));
assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph");
}
}
+36 -25
View File
@@ -201,9 +201,9 @@ fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character:
Ok((para, chars))
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, ID)?;
let conflict = match p.get("conflicts") {
/// The `conflicts` parameter: how a style that differs under the same name is handled.
fn conflict_param(p: &Value) -> Result<Conflict> {
Ok(match p.get("conflicts") {
None | Some(Value::Null) => Conflict::Redefine,
Some(v) => match v.as_str() {
Some("redefine") => Conflict::Redefine,
@@ -211,7 +211,37 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
Some("autoRename") => Conflict::AutoRename,
_ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")),
},
})
}
/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`).
fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) {
let tag = format!("{kind}:{ours}");
match src.export_tags.get(&format!("{kind}:{theirs}")) {
Some(t) => dst.export_tags.insert(tag, t.clone()),
None => dst.export_tags.remove(&tag),
};
}
/// A loaded style and one the document keeps can name each other as their base.
fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> {
for (is_para, name) in loaded {
let looped = if *is_para {
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
} else {
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
};
if looped {
let kind = if *is_para { "paragraph" } else { "character" };
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
}
}
Ok(())
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, ID)?;
let conflict = conflict_param(p)?;
let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?);
let doc = s.doc()?.doc.clone();
let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?;
@@ -231,11 +261,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style));
swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned());
lists.extend(style.para.list_name.iter().cloned());
let tag = format!("p:{}", style.name);
match src.styles.export_tags.get(&format!("p:{}", theirs.name)) {
Some(t) => styles.export_tags.insert(tag, t.clone()),
None => styles.export_tags.remove(&tag),
};
carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name);
loaded.push((true, style.name.clone()));
match styles.para_mut(&style.name) {
Some(m) if action == Action::Replace => {
@@ -256,11 +282,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
chars.follow(&mut style.name);
style.based_on.iter_mut().for_each(|n| chars.follow(n));
swatches.extend(char_swatches(&style.chars).cloned());
let tag = format!("c:{}", style.name);
match src.styles.export_tags.get(&format!("c:{}", theirs.name)) {
Some(t) => styles.export_tags.insert(tag, t.clone()),
None => styles.export_tags.remove(&tag),
};
carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name);
loaded.push((false, style.name.clone()));
match styles.char_style_mut(&style.name) {
Some(m) if action == Action::Replace => {
@@ -275,18 +297,7 @@ fn load(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// A loaded style and one the document keeps can name each other as their base.
for (is_para, name) in &loaded {
let looped = if *is_para {
styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b))
} else {
styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b))
};
if looped {
let kind = if *is_para { "paragraph" } else { "character" };
return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well")));
}
}
check_based_on_cycles(&styles, &loaded)?;
let swatches = missing_swatches(&doc, &src, swatches);
let lists: Vec<NumberedList> = {
+1
View File
@@ -17,6 +17,7 @@ mod endnotes;
mod export;
mod file;
pub(crate) mod find;
pub use edit::copied_objects_text;
pub use find::same_find_domain;
pub(crate) mod flex;
mod fonts;
+48 -7
View File
@@ -686,7 +686,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Object & Layout", "Align"],
None,
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread (default: keyObject while a key object is set), ids?}",
has_selection,
align
),
@@ -708,7 +708,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::always,
|s, p| {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
s.prefs.polygon_sides = (n as u32).clamp(3, 100);
s.prefs.polygon_sides = n.clamp(3, 100) as u32;
}
if let Some(v) = p.get("starInset").and_then(Value::as_f64) {
s.prefs.star_inset = (v / 100.0).clamp(0.0, 1.0);
@@ -972,7 +972,7 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
let caret = bool_or(p, "caret", content == "text");
let vertical = bool_or(p, "vertical", false);
let sides = p.get("sides").and_then(Value::as_u64).map_or(s.prefs.polygon_sides, |v| v as u32).clamp(3, 100);
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(u64::from(s.prefs.polygon_sides)).clamp(3, 100) as u32;
let inset = p.get("starInset").and_then(Value::as_f64).map_or(s.prefs.star_inset, |v| v / 100.0).clamp(0.0, 1.0);
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
s.edit(|d, sel| {
@@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId {
dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc()))
}
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.
pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
it.id = ItemId(dst.alloc());
let my_id = it.id;
if let Some(m) = it.media.as_mut()
@@ -2516,7 +2517,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
let o = &mut tf.options;
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
o.columns = (v as u32).clamp(1, 40);
o.columns = v.clamp(1, 40) as u32;
}
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
o.gutter = v.max(0.0);
@@ -3073,8 +3074,18 @@ fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>)
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let edge = str_param(p, "edge").unwrap_or("left").to_string();
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
let key = s.doc()?.selection.key;
// A key object counts only while it is one of several items aligned. With one, InDesign's
// Align To switches to Key Object, so it is also the default target.
let key = s.doc()?.selection.key.filter(|k| ids.len() > 1 && ids.contains(k));
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| {
if key.is_some() {
"keyObject".into()
} else if ids.len() > 1 {
"selection".into()
} else {
"page".into()
}
});
s.edit(|d, _| {
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
for id in &ids {
@@ -3787,6 +3798,21 @@ mod polygon_tests {
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3);
}
#[test]
fn polygon_sides_clamp_before_narrowing() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967301u64})).unwrap();
assert_eq!(r["sides"], 100);
let r = s.execute("tool.polygonSettings", &json!({"sides": 4294967297u64})).unwrap();
assert_eq!(r["sides"], 100);
let id =
s.execute("frame.create", &json!({"rect": [0, 0, 50, 50], "shape": "polygon", "sides": 4294967301u64, "starInset": 0})).unwrap()["id"]
.as_u64()
.unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 100);
}
}
#[cfg(test)]
@@ -4497,6 +4523,21 @@ mod column_rule_tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(opts(&s), before);
}
/// A column count past `u32::MAX` was cast to `u32` before the clamp, so
/// 2^32 + 1 wrapped to 1 column instead of clamping to the maximum, 40.
#[test]
fn huge_column_count_clamps_to_the_maximum() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
let id = ItemId(r["id"].as_u64().unwrap());
let columns = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.columns;
for (given, want) in [(4_294_967_297_u64, 40), (u64::MAX, 40), (0, 1)] {
s.execute("object.textFrameOptions", &json!({"ids": [id.0], "columns": given})).unwrap();
assert_eq!(columns(&s), want, "columns {given}");
}
}
}
#[cfg(test)]
+3 -2
View File
@@ -135,8 +135,9 @@ impl OutputRoot {
{
use std::os::unix::fs::PermissionsExt;
// Match fs::write for a new output: 0666 restricted by the caller's umask.
builder.permissions(std::fs::Permissions::from_mode(0o666));
// Like fs::write for a new output: request 0666 and let the process umask decide the
// effective mode (0644 with the usual 022, 0664 with 002).
builder.permissions(std::fs::Permissions::from_mode(0o666)); // NOSONAR
}
let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?;
#[cfg(unix)]
+266 -4
View File
@@ -42,6 +42,22 @@ pub fn specs() -> Vec<CommandSpec> {
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
return Err(bad("object.layerOptions", "select a placed PDF"));
}
// Validate every selected PDF before editing any item or recording undo.
// Repeated placements of the same source need only one rewrite check.
if !hidden.is_empty() {
let mut checked = std::collections::HashSet::new();
for id in &ids {
if let Some(data) = placed_pdf(s, *id)
&& checked.insert(std::sync::Arc::as_ptr(&data))
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
{
return Err(bad(
"object.layerOptions",
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
));
}
}
}
s.edit(|d, _| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
@@ -63,31 +79,277 @@ pub(crate) mod tests {
/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half).
pub(crate) fn two_layer_pdf() -> Vec<u8> {
two_layer_pdf_generation(0)
}
fn two_layer_pdf_generation(generation: i32) -> Vec<u8> {
two_layer_pdf_options(generation, "", "")
}
fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec<u8> {
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
let objs = [
"<</Type/Catalog/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]>>>>>>".to_string(),
format!(
"<</Type/Catalog/Lang(en-GB)/Metadata 7 0 R/OutputIntents[8 0 R]/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]{config}>>>>>>"
),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</Type/OCG/Name(Blue)>>".to_string(),
"<</Type/OCG/Name(Red)>>".to_string(),
"<</Type/Metadata/Subtype/XML/Length 4>>\nstream\n<x/>\nendstream".to_string(),
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier(Test)>>".to_string(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes());
}
let x = out.len();
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
for o in offs {
t.push_str(&format!("{o:010} 00000 n\r\n"));
t.push_str(&format!("{o:010} {generation:05} n\r\n"));
}
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
t.push_str(&format!("trailer\n<</Size {}/Root 1 {generation} R{trailer}>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
out.extend_from_slice(t.as_bytes());
out
}
#[test]
fn pdf_layer_update_preserves_catalog_configuration_and_generations() {
for generation in [0, 1] {
let original = two_layer_pdf_generation(generation);
let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let appended = std::str::from_utf8(&updated[original.len()..]).unwrap();
for token in [
"/Lang(en-GB)".to_string(),
format!("/Metadata 7 {generation} R"),
format!("/OutputIntents[8 {generation} R]"),
format!("/Locked[5 {generation} R]"),
"/Name(Default)".into(),
format!("/Root 1 {generation} R"),
] {
assert!(appended.contains(&token), "missing {token}: {appended}");
}
assert!(!appended.contains("9999000"));
let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap();
assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible));
}
}
#[test]
fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let mut stream_pdf = original[..offset].to_vec();
let mut entries = vec![0, 0, 0, 0, 0, 255, 255];
for number in 1..=9 {
let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() };
entries.push(1);
entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes());
entries.extend_from_slice(&[0, 0]);
}
stream_pdf.extend_from_slice(format!("9 0 obj\n<</Type/XRef/Size 10/Root 1 0 R/W[1 4 2]/Length {}>>\nstream\n", entries.len()).as_bytes());
stream_pdf.extend_from_slice(&entries);
stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] {
let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9"));
assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none());
}
}
#[test]
fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() {
let original = two_layer_pdf();
let text = std::str::from_utf8(&original).unwrap();
let first = original.len();
let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<</Size 9/Root 1 0 R/Prev {prev:010}>>\n");
let main = first + link(0).len();
let mut forward = original.clone();
forward.extend_from_slice(link(main).as_bytes());
let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes());
forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap();
assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]);
let mut cyclic = original;
cyclic.extend_from_slice(link(first).as_bytes());
cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none());
}
fn add_pdf_revision(bytes: &mut Vec<u8>) {
let text = std::str::from_utf8(bytes).unwrap();
let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap();
let xref = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<</Size 9/Root 1 0 R/Prev {prev}>>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
);
}
fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value {
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100}))
.unwrap()["id"]
.clone()
}
fn pixel(s: &Session, x: u32) -> [u8; 4] {
let mut renderer = designcraft_render::Renderer::new();
renderer.threads = 0;
renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50)
}
#[test]
fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() {
for generation in [0, 1] {
let bytes = two_layer_pdf_options(generation, "", "/Encrypt null");
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let id = place(&mut s, &bytes, 0.0);
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false);
assert_eq!(pixel(&s, 25), [0, 0, 255, 255]);
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
s.execute("file.exportPdf", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(pixel(&s, 75), [255, 255, 255, 255]);
}
}
#[test]
fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() {
let mut unsupported = two_layer_pdf();
for _ in 0..32 {
add_pdf_revision(&mut unsupported);
}
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
let good = place(&mut s, &two_layer_pdf(), 0.0);
let bad = place(&mut s, &unsupported, 100.0);
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().history.redo.is_empty());
let before = s.execute("file.serialize", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let redo = s.doc().unwrap().history.redo.len();
for ids in [json!([good, bad]), json!([bad, good])] {
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
assert!(err.to_string().contains("could not be hidden"));
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
for id in [&good, &bad] {
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true);
}
}
assert_eq!(pixel(&s, 75), [255, 0, 0, 255]);
assert_eq!(pixel(&s, 175), [255, 0, 0, 255]);
// A persisted/relinked choice can be unrewritable. Reset must still work.
let bad_id = serde_json::from_value(bad.clone()).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()];
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas");
assert!(s.execute("file.exportPdf", &json!({})).is_err());
s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap();
assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true);
s.execute("file.exportPdf", &json!({})).unwrap();
}
#[test]
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}"));
assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}");
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}");
}
for trailer in ["", "/Prev null"] {
let bytes = two_layer_pdf_options(0, "", trailer);
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}");
}
let mut revised = two_layer_pdf();
let prev =
std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::<usize>().unwrap();
add_pdf_revision(&mut revised);
let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>"));
assert!(real_prev.contains(&format!("/Prev {prev}.0>>")));
for bytes in [revised, real_prev.into_bytes()] {
assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work");
}
}
#[test]
fn fake_null_trailer_cannot_bypass_hybrid_rejection() {
for real_guard in ["", "/XRefStm 0"] {
let bytes = two_layer_pdf_options(0, "", real_guard);
let text = std::str::from_utf8(&bytes).unwrap();
for fake in ["/Encrypt null", ""] {
let comment = format!("% trailer <</Size 9/Root 1 0 R{fake}>>\n");
let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1);
assert!(inside.contains(&format!("xref\n{comment}")));
// Repair-only comments inside a table are always refused, guard or not.
assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2);
assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none());
// Before xref or after the dictionary, the real trailer decides.
let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1);
let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1);
for source in [leading, trailing] {
assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2);
assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty());
}
}
}
}
#[test]
fn content_all_still_measures_layerless_and_stub_properties() {
use designcraft_doc::PdfCrop;
for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<</OCGs[]/D<<>>>>"] {
let content = "0 0 1 rg 0 0 50 100 re f";
let objs = [
format!("<</Type/Catalog/Pages 2 0 R{props}>>"),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".into(),
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Contents 4 0 R>>".into(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, body) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n");
for at in offsets {
bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<</Size 5/Root 1 0 R>>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
assert!(designcraft_render::pdf_layers(&bytes).is_empty());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}");
}
}
#[test]
fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() {
use designcraft_doc::PdfCrop;
let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", "");
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0));
for _ in 0..32 {
add_pdf_revision(&mut bytes);
}
assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none());
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0));
assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0)));
}
#[test]
fn placed_pdf_layers_hide() {
let mut s = Session::new();
+177 -15
View File
@@ -233,11 +233,12 @@ fn same_rect(a: Rect, b: Rect) -> bool {
}
/// Thread new frames until the story fits: first into the columns after the last frame's column
/// on its page (when it sits in one), then one frame per column on each page it adds. With
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
/// removed again, except the first on a page it didn't add; a page it added that holds only such
/// a frame goes with it. Returns pages added.
/// on its page (when it sits in one), then one frame per column on each page that follows it,
/// whatever else the page holds, and on each page it adds at the end of the document, as InDesign
/// does. With `primary` (Smart Text Reflow of the primary text frame), pages are added right after
/// the last frame's page, each with one frame on the live area with the page's columns instead.
/// Empty frames it made at the end of the thread are removed again; a page it added that holds
/// only such a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
@@ -252,6 +253,8 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let mut made = Vec::new();
let mut new_pages = Vec::new();
let mut first = true;
// The next page of the document autoflow may set frames on; it only moves forward.
let mut following = 0;
loop {
let (overset, held, full) = fit(d, sid);
let Some(at) = overset else { break };
@@ -267,16 +270,33 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let rects = flow_rects(d, dir, vertical, page);
let next = d.item(last).and_then(|it| rects.iter().position(|r| same_rect(*r, it.bounds()))).map_or(rects.len(), |k| k + 1);
if let (Some(rest), Some((si, _))) = (rects.get(next..).filter(|r| !r.is_empty()), d.page_loc(page)) {
for r in rest.iter().take(want) {
// Whole pages: an estimate that stops inside one would leave its later columns out
// of the thread for good. The cleanup below takes the frames the text doesn't reach.
for r in rest {
prev = add_flow_frame(d, si, *r, layer, prev)?;
made.push(prev);
}
continue;
}
}
following = following.max(page + 1);
if !primary && following < d.page_count() {
let mut left = want;
while left > 0 && following < d.page_count() {
let Some((si, _)) = d.page_loc(following) else { break };
for r in flow_rects(d, dir, vertical, following) {
prev = add_flow_frame(d, si, r, layer, prev)?;
made.push(prev);
left = left.saturating_sub(1);
}
following += 1;
}
continue;
}
if added >= max_pages {
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
}
// Past the following pages, `page` is the document's last: autoflow adds pages at its end.
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.column_rects().len().max(1)) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
@@ -304,16 +324,23 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
}
let page = d.page_of_item(f);
if d.page_of_item(*before) != page {
// The first frame on its page: the estimate added more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds.
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
// The first frame on its page: the estimate asked for more pages than the text needs.
// Drop the page too when autoflow added it and the frame is all it holds; a page the
// document had keeps whatever else is on it.
match page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) {
Some(pg) => {
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
if !alone {
break;
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
}
None => {
d.remove_item(f)?;
}
}
d.remove_item(f)?;
d.delete_pages(&[pg])?;
added = added.saturating_sub(1);
} else {
d.remove_item(f)?;
}
@@ -568,6 +595,141 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A facing-pages document with two columns, its inside margin wider than the outside one, so
/// left and right pages have their columns in different places. A graphic frame sits on the
/// page `marker` (0-based). Returns the session and the marker's id.
fn facing_document(pages: usize, marker: usize) -> (Session, u64) {
let mut s = Session::new();
let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36});
s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins}))
.unwrap();
let d = &s.doc().unwrap().doc;
let (si, _) = d.page_loc(marker).unwrap();
let m = d.page(marker).unwrap().margin_rect();
let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0];
let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
// Selected, an empty frame would take the placed text.
s.execute("edit.deselectAll", &json!({})).unwrap();
(s, id)
}
/// Every frame of the story on the column of its page, the story running through the pages in
/// order, two frames a page.
fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) {
for (i, f) in frames.iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, i / 2, "frame {i}");
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}");
}
}
#[test]
fn autoflow_fills_the_pages_that_follow_before_adding_any() {
// Gap 26: autoflow inserted pages after the first frame's page, in front of the empty
// pages the document already had. InDesign threads frames on those first.
let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap();
let (mut s, marker) = facing_document(6, 2);
let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = d.story(sid).unwrap().frames.clone();
// Past page 3, which holds the marker: frames go on a page whatever else it holds.
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len());
assert_frames_in_columns(d, &frames);
// The text ends before the document does: no empty frame after it.
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())));
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_pages_at_the_end_of_the_document() {
let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap();
let (mut s, marker) = facing_document(3, 2);
let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
let d = &s.doc().unwrap().doc;
assert!(d.page_count() > 4, "{r}");
assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}");
// The document's own pages come first, the added ones after the last of them.
assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2));
let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone();
assert_frames_in_columns(d, &frames);
assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1));
// One undo step takes the frames, the pages and the story away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::<Vec<_>>(), ids);
assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_fills_every_column_of_a_following_page() {
// A first frame across the whole live area holds twice what a column does, so autoflow
// asks for half the frames it needs. Asked page by page, it left the later columns of a
// page out of the thread and went on to the next page.
let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap();
let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames;
assert_eq!(d.item(frames[0]).unwrap().bounds(), m);
assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len());
for (k, f) in frames[1..].iter().enumerate() {
let page = d.page_of_item(*f).unwrap();
assert_eq!(page, 1 + k / 2, "frame {}", k + 1);
assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() {
// A small first frame makes autoflow ask for more frames than the columns need; the ones
// it set on the following pages past the end of the text go again, the pages stay.
let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("long.txt");
std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap();
let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap();
assert_eq!(r["overset"], false, "{r}");
assert_eq!(r["pagesAdded"], 0, "{r}");
let d = &s.doc().unwrap().doc;
assert_eq!(d.page_count(), 8);
let sid = StoryId(r["story"].as_u64().unwrap());
let frames = &d.story(sid).unwrap().frames;
assert!(frames.len() >= 2, "{r}");
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len());
assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::<usize>(), frames.len());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autoflow_adds_only_the_pages_breaks_ask_for() {
// Regression (#262): a frame a break ends early made autoflow take it for a frame that
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print…",
["File"],
Some("Cmd+P"),
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing)} → {printer, copies, pages, command, bytes}",
"{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}",
has_doc,
print
),
@@ -95,7 +95,8 @@ mod tests {
fn print_submits_the_selected_export_and_reports_refusal() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4})).unwrap();
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true});
// A fixed creation date: both exports would otherwise take the clock's second.
let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000});
let expected = {
let d = &s.doc().unwrap().doc;
let options = super::super::export::options(&params, d.page_count()).unwrap();
@@ -118,5 +119,6 @@ mod tests {
assert!(refusal.to_string().contains("printer offline"));
print_with(&mut s, &json!({"dryRun": true}), |_, _| panic!("dry runs must not spool")).unwrap();
assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err());
assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err());
}
}
+59 -23
View File
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
"Cell Options",
[],
None,
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -971,6 +971,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
// Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is
// composed so far, which the IDML import reports (#1468).
let rotation = match p.get("rotation").filter(|v| !v.is_null()) {
Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) {
Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d),
_ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")),
},
None => None,
};
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -1021,6 +1030,9 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = first_baseline_min {
cell.first_baseline_min = v;
}
if let Some(v) = rotation {
cell.rotation = v;
}
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1261,6 +1273,35 @@ fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<V
out.iter().all(|v| v.is_finite()).then_some(out)
}
/// The frame a border drag names must hold the table's story and be editable.
fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> {
const ID: &str = "table.resizeBorder";
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
Ok(())
}
/// The change in track size: `delta`, or `size` measured from the current size `cur`.
fn border_delta(p: &Value, cur: f64) -> Result<f64> {
const ID: &str = "table.resizeBorder";
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
Ok(delta)
}
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.resizeBorder";
let column = match str_param(p, "axis") {
@@ -1272,15 +1313,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
let g = target(s, p, ID)?;
let st = s.doc()?;
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(g.story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
check_border_frame(st, p, g.story)?;
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let sizes: Vec<f64> = if column {
t.columns.iter().map(|c| c.width).collect()
@@ -1289,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
};
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
let delta = border_delta(p, cur)?;
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
let size = new.get(index).copied().unwrap_or(cur);
let scaled = keep && index + 1 == sizes.len();
@@ -1393,14 +1419,14 @@ fn parse_alt(v: &Value) -> Option<AltFills> {
}
let d = AltFills::default();
Some(AltFills {
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| x as u32),
first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)),
first_color: color_param(v, "firstColor").unwrap_or(d.first_color),
first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| x as u32),
next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)),
next_color: color_param(v, "nextColor").unwrap_or(d.next_color),
next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| x as u32),
skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)),
})
}
@@ -1548,7 +1574,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1832,6 +1858,16 @@ mod style_tests {
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
// Cell Options › Text › Rotation: carried and written back, composed horizontally so far,
// which the IDML import reports (#1468).
for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] {
s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}"));
let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone();
assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}");
}
for bad in [45, 1, 100] {
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}");
}
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
+57 -1
View File
@@ -1502,12 +1502,29 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// A character fill must name a swatch of the document.
pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> {
validate_char_size(attrs, command_id)?;
match &attrs.fill {
Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"),
None => Ok(()),
}
}
/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's
/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative
/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page.
pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> {
match attrs.size {
Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => {
Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt")))
}
_ => Ok(()),
}
}
/// Type size limits (InDesign's, and the ones `type.ruby` already uses).
pub(crate) const MIN_TYPE_SIZE: f64 = 0.1;
pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0;
/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that
/// line, and so the one a line-affecting attribute like leading can land on right away instead of
/// waiting for the next typed character (otherwise an empty line could never get its own leading).
@@ -2088,7 +2105,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result<Value> {
continue;
}
let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect();
let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None };
let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None };
let (applied, available) = measure(&tag);
sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available}));
}
@@ -3911,3 +3928,42 @@ mod pick_up_format_tests {
let _ = ItemId(rect);
}
}
#[cfg(test)]
mod size_bounds_tests {
use serde_json::json;
use crate::Session;
/// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative
/// size was accepted the same way, and 1e9 pt asked the composer for a line a million times
/// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt.
#[test]
fn a_type_size_outside_the_range_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
let select = |s: &mut Session| {
s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap();
};
for size in [0.0, -12.0, 0.05, 1297.0, 1e9] {
select(&mut s);
let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string();
assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}");
}
// The sizes in range still apply, including the ends.
for size in [0.1, 12.0, 1296.0] {
select(&mut s);
s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}"));
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size));
}
// And the styles that carry character attributes are checked the same way.
for (command, params) in [
("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})),
("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})),
] {
let e = s.execute(command, &params).expect_err("refused").to_string();
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Thread Text Frames",
[],
None,
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then thread a frame per column on the pages that follow, adding pages at the end of the document until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
has_doc,
thread
),
+47 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Hyperlink Destination…",
["Type", "Hyperlinks & Cross-References"],
None,
"{name} — a text anchor at the insertion point",
"{name} — a text anchor at the insertion point; the name is the document's, so it must not be one another anchor already has",
in_story_text,
create_anchor
),
@@ -103,6 +103,14 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
fn create_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
if name.trim().is_empty() {
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
}
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
// `hyperlink.create` take the first they find, and a file writes both under one name.
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
}
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?;
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
@@ -215,6 +223,44 @@ mod tests {
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
}
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
/// exported file writes both destinations under one name.
#[test]
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
};
select(&mut s, a["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
// The same name again, in the same story and in another one.
select(&mut s, a["story"].clone(), 10);
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
assert!(e.contains("already in this document"), "{e}");
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
select(&mut s, b["story"].clone(), 0);
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
// A name of spaces is not a name.
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
// One anchor, so a reference by name can only mean that one.
let anchors = s.execute("anchor.list", &json!({})).unwrap();
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
// Another name is fine.
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
// The check is on the name, not on where it is: a name that differs only in case is a
// different name, as it is everywhere else in the document.
select(&mut s, b["story"].clone(), 0);
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
}
#[test]
fn cross_reference_into_a_story_with_a_running_header_composes() {
let mut s = Session::new();
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into())
}
+1 -2
View File
@@ -21,8 +21,7 @@ use std::path::{Path, PathBuf};
/// refused path is never handed to the file system; callers treat it as a missing file.
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
let path = craft_docpath::resolve(stored, document.as_deref())?;
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
craft_docpath::resolve_absolute(stored, document.as_deref())
}
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
+30 -20
View File
@@ -542,17 +542,7 @@ impl Session {
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
check_params(id, params)?;
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
self.check_enabled(spec, id, params)?;
// Preview is temporary. Save, merge, and every other edit restore the template first.
// Queries stay read-only so the panel can poll fields during a preview. `data.preview`
// itself is the one command that fills the live document.
@@ -598,11 +588,38 @@ impl Session {
st.doc = Arc::new(d);
}
}
// Auto-size frames fit the text the command changed, in the same undo step.
self.autosize_changed(spec, before.as_ref());
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Fails with `Disabled` unless the command is enabled or names objects it can act on.
fn check_enabled(&self, spec: &CommandSpec, id: &str, params: &Value) -> Result<()> {
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
Ok(())
}
/// Auto-size frames fit the text the command changed, in the same undo step.
fn autosize_changed(&mut self, spec: &CommandSpec, before: Option<&(u64, Arc<Document>, Selection)>) {
if spec.undoable
&& let Some(st) = self.active_mut()
{
let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
let prev = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) {
let todo = autosize::candidates(&st.doc, prev);
if !todo.is_empty() {
@@ -614,13 +631,6 @@ impl Session {
}
}
}
self.keep_guide_selection_valid(before.as_ref(), id);
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Drops selected guides the command `id` made stale, against the document as it will be
+2 -2
View File
@@ -333,7 +333,7 @@ impl Parser<'_> {
'+' => Node::Sym("+".into(), Class::Bin),
'-' => Node::Sym("−".into(), Class::Bin),
'*' => Node::Sym("∗".into(), Class::Bin),
'=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel),
'<'..='>' => Node::Sym(c.to_string(), Class::Rel),
',' | ';' => Node::Sym(c.to_string(), Class::Punct),
'\'' => Node::Sym("′".into(), Class::Plain),
c => Node::Sym(c.to_string(), Class::Plain),
@@ -390,7 +390,7 @@ impl Parser<'_> {
if self.peek().is_none() {
break;
}
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(|c| c.is_ascii_alphabetic()) {
if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) {
self.i += 6;
break;
}
+5 -5
View File
@@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) {
let path = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok());
let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::<u64>().is_ok());
// Metadata whose document has gone, and a file a crash left half written.
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
left.push(path);
@@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result<std::collections::HashSet<u64>> {
for entry in entries {
let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
let path = entry.path();
if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok())
if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft"))
&& let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok())
{
ids.insert(uid);
}
@@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("designcraft") {
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = std::fs::read_to_string(p.with_extension("json"))
.ok()
+105
View File
@@ -1651,3 +1651,108 @@ fn a_line_fits_when_its_baseline_does() {
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
}
/// Issue #1637: with several objects selected, a click on one of them (no drag) makes it the key
/// object, and Align to Key Object leaves it in place. A second click on it drops the key.
#[test]
fn a_click_in_the_selection_makes_the_key_object() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
// On the part of the key object that the aligned object never covers, away from the handles.
let click = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 160.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 160.0), v).unwrap();
};
click(&mut s);
assert_eq!(s.doc().unwrap().selection.items.len(), 2, "the click keeps both selected");
// The key is the right-hand object, so aligning left edges to the selection would move it.
s.execute("object.align", &json!({"edge": "left", "to": "keyObject"})).unwrap();
let x0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().x0;
assert!((x0(&s, b) - 200.0).abs() < 1e-6, "the key object moved to {}", x0(&s, b));
assert!((x0(&s, a) - 200.0).abs() < 1e-6, "the other object is at {}", x0(&s, a));
// The Align panel and the Control panel send no target: the key object is the default one.
s.execute("object.align", &json!({"edge": "top"})).unwrap();
let y0 = |s: &Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds().y0;
assert!((y0(&s, b) - 120.0).abs() < 1e-6, "the key object moved to {}", y0(&s, b));
assert!((y0(&s, a) - 120.0).abs() < 1e-6, "the other object is at {}", y0(&s, a));
click(&mut s);
assert_eq!(s.doc().unwrap().selection.key, None, "a click on the key object drops the key");
}
/// Dragging one of several selected objects moves them all and makes no key object.
#[test]
fn dragging_the_selection_makes_no_key_object() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
s.pointer(&PointerEvent::new(PointerKind::Down, 230.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 135.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 135.0), v).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
}
/// Shift-clicking the key object out of the selection drops the key.
#[test]
fn deselecting_the_key_object_drops_the_key() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 100.0, 260.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(designcraft_doc::ItemId(b)));
s.execute("selection.toggle", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
}
/// A key object needs another object to align to: with one object selected there is nothing to key
/// against, and the stored key would turn that object into the target as soon as a second one joins.
#[test]
fn a_single_selected_object_cannot_be_the_key_object() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
assert!(s.execute("selection.key", &json!({"id": a})).is_err());
assert_eq!(s.doc().unwrap().selection.key, None);
s.execute("selection.toggle", &json!({"id": b})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None, "the second object joining must not find a key already set");
}
/// With the selection down to the key object alone, Align no longer has a key object: a single
/// object aligns to the page, as before.
#[test]
fn a_lone_key_object_aligns_to_the_page() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
s.execute("selection.toggle", &json!({"id": a})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, None);
s.execute("object.align", &json!({"edge": "left"})).unwrap();
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
}
/// Aligning one object alone (`ids`) while it is the key object of a larger selection also takes
/// the page.
#[test]
fn aligning_the_key_object_alone_takes_the_page() {
let mut s = session();
let a = s.execute("frame.create", &json!({"rect": [50.0, 100.0, 100.0, 150.0]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("frame.create", &json!({"rect": [200.0, 120.0, 300.0, 170.0]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
s.execute("selection.key", &json!({"id": b})).unwrap();
s.execute("object.align", &json!({"edge": "left", "ids": [b]})).unwrap();
let x0 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds().x0;
assert!(x0.abs() < 1e-6, "the object is at {x0}, not at the page's left edge");
}
+70
View File
@@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() {
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_relative_link_in_an_unsaved_document_is_missing() {
// With no document path there is nothing to resolve against; the working directory is not used.
let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]);
s.doc_mut().unwrap().path = None;
let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
assert!(seen.is_empty(), "{seen:?}");
for row in rows.as_array().unwrap() {
assert_eq!(row["status"], "missing", "{row}");
assert_eq!(row["refused"], "the path is not valid", "{row}");
}
}
#[cfg(unix)]
#[test]
fn relink_folder_never_leaves_the_chosen_folder() {
// A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere.
let dir = folder("relink-outside");
let chosen = dir.join("chosen");
std::fs::create_dir_all(&chosen).unwrap();
let outside = dir.join("outside.png");
std::fs::write(&outside, png()).unwrap();
let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F"));
let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&outside), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn relink_folder_takes_the_chosen_folder_as_one_folder() {
// `a\b` is one folder name on Unix; read as a stored path it would be `a/b`.
let dir = folder("relink-backslash");
let chosen = dir.join("a\\b");
std::fs::create_dir_all(&chosen).unwrap();
std::fs::create_dir_all(dir.join("a/b")).unwrap();
let elsewhere = dir.join("a/b/x.png");
std::fs::write(&elsewhere, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) =
crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap());
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&elsewhere), "{seen:?}");
// A folder given with `..` is still that folder.
let real = dir.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("x.png"), png()).unwrap();
let roundabout = real.join("..").join("real");
let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap();
assert_eq!(out["relinked"], 1, "{out}");
// Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`.
let inner = dir.join("inner");
std::fs::create_dir_all(inner.join("sub")).unwrap();
std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap();
let beside_alias = dir.join("x.png");
std::fs::write(&beside_alias, png()).unwrap();
let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]);
let (out, seen) = crate::io::touched(|| {
s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap()
});
assert_eq!(out["relinked"], 0, "{out}");
assert!(!seen.contains(&beside_alias), "{seen:?}");
let _ = std::fs::remove_dir_all(&dir);
}
+23
View File
@@ -1097,3 +1097,26 @@ fn convert_rows_keeps_merged_cells_on_one_side() {
assert!(!convert_enabled(&s, "Body"));
check(&s);
}
#[test]
fn alternating_fill_counts_saturate_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap();
s.execute("table.options", &json!({"altRows": {"first": 4294967297u64, "next": 1, "skipFirst": 4294967296u64, "skipLast": 9999999999u64}}))
.unwrap();
let t = s.execute("table.get", &json!({})).unwrap();
let a = &t["options"]["altRows"];
assert_eq!(a["first"], u32::MAX);
assert_eq!(a["skipFirst"], u32::MAX);
assert_eq!(a["skipLast"], u32::MAX);
assert_eq!(a["next"], 1);
}
#[test]
fn index_next_paragraphs_saturates_instead_of_wrapping() {
let (mut s, _, _) = session_with_frame();
s.execute("index.addReference", &json!({"topic": "Intro", "range": "nextParagraphs", "paragraphs": 4294967296u64})).unwrap();
let l = s.execute("index.list", &json!({})).unwrap();
assert_eq!(l[0]["range"]["type"], "nextParagraphs");
assert_eq!(l[0]["range"]["value"], u32::MAX);
}
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
use std::path::Path;
@@ -1,5 +1,4 @@
//! Browser Print must reject native spooler operations without trapping the editor.
#![cfg(target_arch = "wasm32")]
use designcraft_engine::Session;
use serde_json::json;
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use designcraft_engine::Session;
use serde_json::json;
+23
View File
@@ -0,0 +1,23 @@
#[cfg(not(target_arch = "wasm32"))]
mod book_safety;
#[cfg(target_arch = "wasm32")]
mod browser_print;
mod character_style_from_selection;
mod close_retention;
#[cfg(not(target_arch = "wasm32"))]
mod collection_state_atomicity;
mod combined_close_glyph;
mod distribute_locks;
mod export_page_input;
mod insert_pages_bounds;
mod param_keys;
mod pdf_backend;
mod preflight_resolution;
mod primary_frame_columns;
#[cfg(not(target_arch = "wasm32"))]
mod save_state_atomicity;
mod stroke_tint_range;
mod text_decorations;
mod text_insert_memory;
mod text_thread_regressions;
mod thread_color_safety;
@@ -207,7 +207,9 @@ fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() {
session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
let placed =
session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap();
// Commands now reject unrewritable choices. Simulate stale persisted state after relinking.
let id: designcraft_doc::ItemId = serde_json::from_value(placed["id"].clone()).unwrap();
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc).item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
let before = session.execute("file.serialize", &json!({})).unwrap();
let result = session.execute("file.exportPdf", &json!({}));
assert!(result.is_err(), "failed layer hiding must not export the original visible layers");
@@ -1,5 +1,3 @@
#![cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use std::sync::Arc;
+34 -26
View File
@@ -785,6 +785,39 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
close_blocks(&mut list, &mut pre, out);
}
/// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects
/// and notes where their marks are.
fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String {
let doc = self.doc;
let mut t = String::new();
let mut seg_start = 0;
// Index of the next anchored object: marks count from the story start, as
// `Story::object_at` counts them (counted once, at the run's first mark).
let mut mark: Option<usize> = None;
for (i, c) in raw.char_indices() {
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
continue;
}
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
seg_start = i + c.len_utf8();
if c == OBJECT_MARK {
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
mark = Some(k.saturating_add(1));
if let Some(o) = st.objects.get(k) {
t.push_str(&self.object(o));
}
} else if depth < MAX_DEPTH {
if c == FOOTNOTE_REF {
self.footnote(st, a + i, depth, &mut t, after);
} else {
self.endnote(st, a + i, depth, &mut t);
}
}
}
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
t
}
/// The runs of paragraph `para` of `st`; footnotes go to `after`.
fn runs(&mut self, st: &Story, para: std::ops::Range<usize>, depth: usize, out: &mut String, after: &mut String) {
let doc = self.doc;
@@ -799,32 +832,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> {
continue;
}
let Some(raw) = st.text.get(a..b) else { continue };
let mut t = String::new();
let mut seg_start = 0;
// Index of the next anchored object: marks count from the story start, as
// `Story::object_at` counts them (counted once, at the run's first mark).
let mut mark: Option<usize> = None;
for (i, c) in raw.char_indices() {
if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK {
continue;
}
t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default()));
seg_start = i + c.len_utf8();
if c == OBJECT_MARK {
let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count()));
mark = Some(k.saturating_add(1));
if let Some(o) = st.objects.get(k) {
t.push_str(&self.object(o));
}
} else if depth < MAX_DEPTH {
if c == FOOTNOTE_REF {
self.footnote(st, a + i, depth, &mut t, after);
} else {
self.endnote(st, a + i, depth, &mut t);
}
}
}
t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default()));
let t = self.run_markup(st, raw, a, depth, after);
let (ctag, cls) = if f.style != story::NO_CHAR_STYLE {
let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc);
(tag, format!(" class=\"{}\"", xml_attr(&class)))
+2
View File
@@ -0,0 +1,2 @@
mod export_tag_styles;
mod grouped_reading_order;
+2 -2
View File
@@ -1,7 +1,7 @@
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
//! `fonts/manifest.txt` are embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty. Web
//! (wasm32) builds embed only the UI face, BIZ UDPGothic Regular, to stay within the web size
//! (wasm32) builds embed only the UI faces, BIZ UDPGothic Regular and Noto Sans Georgian Regular, to stay within the web size
//! budget. It only reads the local checkout: no network.
use std::fmt::Write as _;
use std::path::PathBuf;
@@ -38,7 +38,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
};
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Georgian", "Regular")) {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+34 -34
View File
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
@@ -148,12 +148,12 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
let mut w = LEAKED.write().unwrap_or_else(PoisonError::into_inner);
if w.len() <= id {
w.resize(id + 1, None);
}
@@ -579,7 +579,7 @@ fn script_chain(c: char) -> Option<&'static [&'static str]> {
/// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order
/// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`].
fn japanese_chain() -> &'static [&'static str] {
static CHAIN: std::sync::OnceLock<Vec<&'static str>> = std::sync::OnceLock::new();
static CHAIN: std::sync::OnceLock<Vec<&str>> = std::sync::OnceLock::new();
CHAIN.get_or_init(|| {
let mut chain: Vec<&'static str> = Vec::new();
for f in crate::japanese_document_fonts() {
@@ -1037,7 +1037,7 @@ pub fn same_style(a: &str, b: &str) -> bool {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
@@ -1107,7 +1107,7 @@ impl FontDb {
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on;
}
#[cfg(target_arch = "wasm32")]
let _ = on;
@@ -1123,20 +1123,20 @@ impl FontDb {
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
self.faces.read().unwrap_or_else(PoisonError::into_inner)
}
/// The system font catalog, scanned first if it hasn't been yet.
#[cfg(not(target_arch = "wasm32"))]
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
self.ensure_catalog();
self.catalog.read().unwrap_or_else(|e| e.into_inner())
self.catalog.read().unwrap_or_else(PoisonError::into_inner)
}
/// What the document with font scope `scope` sees: its own fonts ahead of the shared ones.
/// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts.
pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> {
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() };
let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() };
ScopedFonts { db: self, own }
}
@@ -1187,7 +1187,7 @@ impl FontDb {
/// family's bold without its regular), skipping a family and style another thread has added
/// meanwhile. Returns the number added.
fn insert_faces(&self, new: Vec<FontFace>) -> usize {
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner);
let mut added = 0;
for f in new {
if !has_face(&faces, &f.family, &f.style) {
@@ -1238,7 +1238,7 @@ impl FontDb {
let count = first.unwrap_or_else(|| self.scan_font_dirs());
// Retry characters and CJK chain families that newly installed fonts can now cover.
// Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan.
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
sys.generation = sys.generation.wrapping_add(1);
sys.misses.clear();
sys.chain_tried.clear();
@@ -1257,7 +1257,7 @@ impl FontDb {
// Rescan first: a lookup after the faces are dropped finds the new files.
let cataloged = self.load_system_fonts();
let changed: Vec<std::path::PathBuf> = {
let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner());
let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner);
let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect();
changed.sort();
for p in &changed {
@@ -1268,15 +1268,15 @@ impl FontDb {
if !changed.is_empty() {
let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p));
let mut dropped = std::collections::HashSet::new();
self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| {
self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| {
let keep = !stale(f);
if !keep {
dropped.insert(f.id);
}
keep
});
self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face));
self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face));
log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len());
}
ReloadedFonts { cataloged, changed }
@@ -1291,7 +1291,7 @@ impl FontDb {
fn read_installed(&self, path: &std::path::Path) -> Option<Vec<u8>> {
let stamp = file_stamp(path)?;
let data = std::fs::read(path).ok()?;
self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp);
self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp);
Some(data)
}
@@ -1314,7 +1314,7 @@ impl FontDb {
stack.push(p);
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -1351,7 +1351,7 @@ impl FontDb {
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
*self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found;
n
}
@@ -1399,7 +1399,7 @@ impl FontDb {
let Ok(entries) = std::fs::read_dir(folder) else { return out };
let mut files: Vec<std::path::PathBuf> = Vec::new();
for path in entries.flatten().map(|e| e.path()) {
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -1421,7 +1421,7 @@ impl FontDb {
let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
// The listed length skips oversized files unread.
let meta = std::fs::metadata(path).ok();
let listed = meta.as_ref().map_or(0, |m| m.len());
let listed = meta.as_ref().map_or(0, std::fs::Metadata::len);
let faces = if listed > MAX_DOCUMENT_FONT_BYTES {
Err(too_large())
} else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL {
@@ -1449,7 +1449,7 @@ impl FontDb {
if !own.is_empty() {
out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed);
out.faces = own.len();
self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into());
self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into());
}
out
}
@@ -1466,7 +1466,7 @@ impl FontDb {
room: u64,
) -> Result<(Vec<Arc<FontFace>>, u64), String> {
use std::io::Read;
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path)
if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path)
&& (*len, *m) == (listed, modified)
{
return Ok((faces.clone(), listed));
@@ -1494,14 +1494,14 @@ impl FontDb {
if faces.is_empty() {
return Err("not a font DesignCraft can read".into());
}
self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone()));
self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone()));
Ok((faces, len))
}
/// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their
/// files stay read, for the document opening again.
pub fn close_scope(&self, scope: u32) {
self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope);
self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope);
}
/// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]).
@@ -1542,7 +1542,7 @@ impl FontDb {
return false;
}
let generation = {
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
if !sys.enabled || sys.misses.contains(&c) {
return false;
}
@@ -1558,12 +1558,12 @@ impl FontDb {
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
#[cfg(all(test, target_os = "linux"))]
{
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(|e| e.into_inner()).take();
let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take();
if let Some(hook) = hook {
let _ = hook.reached.send(());
hook.resume.wait();
@@ -1580,7 +1580,7 @@ impl FontDb {
return true;
}
}
let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner);
if sys.generation == generation {
sys.misses.insert(c);
}
@@ -1619,12 +1619,12 @@ impl FontDb {
/// disappears.
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
return p.clone();
}
let drawn = font_outline(face, gid);
let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn });
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
@@ -1986,7 +1986,7 @@ impl ScopedFonts<'_> {
return Some(base);
}
let key = (base.id, style.to_string());
if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) {
return Some(f.clone());
}
let mut coords = base.coords.clone();
@@ -2004,7 +2004,7 @@ impl ScopedFonts<'_> {
}
}
let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone())
Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone())
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
@@ -2077,7 +2077,7 @@ impl ScopedFonts<'_> {
#[cfg(not(target_arch = "wasm32"))]
if !self.db.is_loaded(family) {
// Held while loading, so a parallel lookup waits for the font instead of passing it by.
let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner());
let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner);
if sys.enabled && sys.chain_tried.insert(family) {
self.db.load_cataloged(family);
}
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod system_fonts;
@@ -1,7 +1,5 @@
//! A new session finds the installed system fonts by family name (#22). One test in its own
//! process, so the process-wide database starts fresh, as it does when the app starts.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use designcraft_fonts::{FontDb, system_font_dirs};
+12 -7
View File
@@ -5,12 +5,9 @@ use std::collections::HashMap;
use crate::Point;
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
/// with the opposite winding.
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
/// Pixel-border edges of the mask with the inside on the right (clockwise, y down), by start point.
fn border_edges(mask: &[bool], w: usize, h: usize) -> HashMap<(isize, isize), Vec<(isize, isize)>> {
let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize];
// Pixel-border edges with the inside on the right (clockwise, y down).
let mut next: HashMap<(isize, isize), Vec<(isize, isize)>> = HashMap::new();
let mut add = |a: (isize, isize), b: (isize, isize)| next.entry(a).or_default().push(b);
for y in 0..h as isize {
@@ -32,16 +29,24 @@ pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<
}
}
}
next
}
/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within
/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out
/// with the opposite winding.
pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec<Vec<Point>> {
let mut next = border_edges(mask, w, h);
let mut out = Vec::new();
let mut starts: Vec<(isize, isize)> = next.keys().copied().collect();
starts.sort();
for s in starts {
while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) {
while let Some(first) = next.get_mut(&s).and_then(Vec::pop) {
let mut ring = vec![s];
let mut cur = first;
while cur != s {
ring.push(cur);
match next.get_mut(&cur).and_then(|v| v.pop()) {
match next.get_mut(&cur).and_then(Vec::pop) {
Some(n) => cur = n,
None => break,
}
+10
View File
@@ -286,6 +286,8 @@ pub fn format_measure_prec(pt: f64, unit: Unit, decimals: usize) -> String {
whole += 1.0;
rest = 0.0;
}
// Decide the sign from the rounded value so float noise doesn't print "-0p0".
let neg = neg && (whole > 0.0 || rest > 0.0);
let sep = if unit == Unit::Picas { 'p' } else { 'c' };
format!("{}{}{}{}", if neg { "-" } else { "" }, whole as i64, sep, trim(rest))
}
@@ -304,6 +306,14 @@ mod tests {
(a - b).abs() < 1e-6
}
/// Tiny negatives that round to zero must not print a sign.
#[test]
fn tiny_negative_picas_ciceros_have_no_sign() {
assert_eq!(format_measure(-0.0004, Unit::Picas), "0p0");
assert_eq!(format_measure(-0.0004, Unit::Ciceros), "0c0");
assert_eq!(format_measure(-12.0, Unit::Picas), "-1p0");
}
/// Inches show four decimals, as InDesign's fields do; other units three.
#[test]
fn inches_show_four_decimals() {
+131 -77
View File
@@ -177,7 +177,7 @@ impl<'a> Ex<'a> {
}
fn assign_swatch_ids(&mut self) {
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect();
let mut used: HashSet<String> = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(ToString::to_string).collect();
self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into());
self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into());
self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into());
@@ -309,28 +309,7 @@ impl<'a> Ex<'a> {
);
}
for t in &d.styles.mojikumi_tables {
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
if !t.based_on.is_empty() {
e.set("BasedOnMojikumiSet", &t.based_on);
}
if !t.overrides.is_empty() {
let mut list = El::new("OverrideMojikumiAkiList");
for r in &t.overrides {
list.push(
El::new("OverrideMojikumiAkiType")
.attr("TargetMojikumiClass", r.target_class)
.attr("SideMojikumiClass", r.side_class)
.attr("SideIsAfterTarget", bool_s(r.after))
.attr("Minimum", num(r.minimum))
.attr("Desired", num(r.desired))
.attr("Maximum", num(r.maximum))
.attr("CompressionPriority", r.priority)
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
);
}
e.push(El::new("Properties").child(list));
}
root.push(e);
root.push(mojikumi_table_el(t));
}
root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml"));
root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml"));
@@ -364,28 +343,7 @@ impl<'a> Ex<'a> {
}
// IDML lists layers back to front.
for l in d.layers.iter().rev() {
let color = match names::layer_color_out(l.color) {
Some(n) => p("LayerColor", "enumeration", n),
None => {
let mut e = El::new("LayerColor").attr("type", "list");
for c in l.color {
e.push(p("ListItem", "double", num(c as f64)));
}
e
}
};
let el = El::new("Layer")
.attr("Self", uid(l.id.0))
.attr("Name", &l.name)
.attr("Visible", bool_s(l.visible))
.attr("Locked", bool_s(l.locked))
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
.attr("ShowGuides", bool_s(l.show_guides))
.attr("LockGuides", "false")
.attr("UI", "true")
.attr("Expendable", "true")
.attr("Printable", bool_s(l.printable));
root.push(with_props(el, vec![color]));
root.push(layer_el(l));
}
for (name, _) in masters {
root.push(El::new("idPkg:MasterSpread").attr("src", name));
@@ -1828,33 +1786,7 @@ impl<'a> Ex<'a> {
.attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy)))
.child(margin_el(pg));
let mut pe = pe;
// Ruler guides, measured from the ruler origin written in the preferences: the
// spread's top left, the page's, or the spine (the import reads them the same way).
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let left = if left.is_finite() { left } else { 0.0 };
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
let origin_x = match d.settings.ruler_origin {
designcraft_doc::RulerOrigin::Spread => left,
designcraft_doc::RulerOrigin::Page => pg.x,
designcraft_doc::RulerOrigin::Spine => left + spine,
};
for g in &pg.guides {
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
let mut ge = El::new("Guide")
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
.attr("FitToPage", bool_s(!g.spread))
.attr("ViewThreshold", "5")
.attr("Locked", bool_s(g.locked))
.attr("PageIndex", (first_page + i).to_string())
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
if let Some(l) = g.layer {
ge.set("ItemLayer", uid(l.0));
}
pe.push(ge);
}
push_page_guides(d, sp, pg, &mut pe, first_page + i);
el.push(pe);
}
let origin = Affine::translate((-ox, -oy));
@@ -2012,12 +1944,12 @@ impl<'a> Ex<'a> {
}
if let Content::Text(t) = &it.content {
el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color)));
if let Some((start, inc)) = t.options.baseline_grid {
if let Some(g) = t.options.baseline_grid {
el.push(
El::new("BaselineFrameGridOption")
.attr("UseCustomBaselineFrameGrid", "true")
.attr("StartingOffsetForBaselineFrameGrid", num(start))
.attr("BaselineFrameGridIncrement", num(inc)),
.attr("StartingOffsetForBaselineFrameGrid", num(g.start))
.attr("BaselineFrameGridIncrement", num(g.increment)),
);
}
}
@@ -2414,6 +2346,7 @@ impl<'a> Ex<'a> {
object_point,
ref_point,
keep_within_column,
spine_relative,
} => El::new("AnchoredObjectSetting")
.attr("AnchoredPosition", "Anchored")
.attr("AnchorPoint", names::anchor_out(*object_point))
@@ -2424,7 +2357,8 @@ impl<'a> Ex<'a> {
// IDML measures a left-aligned object's X offset leftwards.
.attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset }))
.attr("AnchorYoffset", num(*y_offset))
.attr("PinPosition", bool_s(*keep_within_column)),
.attr("PinPosition", bool_s(*keep_within_column))
.attr("SpineRelative", bool_s(*spine_relative)),
};
e.push(setting);
out.push(Node::El(e));
@@ -2797,7 +2731,25 @@ impl<'a> Ex<'a> {
ce.set(&format!("{side}StrokePriority"), priority);
}
}
for psr in self.story_paras(&cell.text) {
let mut paras = self.story_paras(&cell.text);
// A graphic cell holds its image as an inline object in the cell's first paragraph.
if let Some(g) = &cell.graphic
&& let Some(object) = self.cell_graphic_el(g)
{
// The cell clips its graphic on screen and in PDF; IDML does so only when asked.
ce.set("ClipContentToCell", bool_s(true));
let mut range = self.csr_el(&CharFormat::default());
range.push(object);
match paras.first_mut() {
Some(first) => first.push(range),
None => {
let mut psr = self.psr_el(&ParaFormat::default());
psr.push(range);
paras.push(psr);
}
}
}
for psr in paras {
ce.push(psr);
}
el.push(ce);
@@ -2806,6 +2758,20 @@ impl<'a> Ex<'a> {
el
}
/// The frame of a table cell's graphic: an inline `Rectangle` around the shown image, or `None`
/// when the graphic's asset is missing.
fn cell_graphic_el(&mut self, g: &designcraft_doc::Graphic) -> Option<El> {
self.d.assets.get(&g.asset)?;
let shown = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
let layer = self.d.layers.first().map(|l| l.id).unwrap_or(designcraft_doc::ids::LayerId(0));
let mut item = Item::new(designcraft_doc::ids::ItemId(0), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(shown));
item.content = Content::Graphic(g.clone());
let mut el = self.item_el(&item, Affine::IDENTITY);
el.set("Self", self.fresh());
el.push(El::new("AnchoredObjectSetting").attr("AnchoredPosition", "InlinePosition").attr("AnchorYoffset", "0"));
Some(el)
}
fn psr_el(&mut self, pf: &ParaFormat) -> El {
let mut el = El::new("ParagraphStyleRange").attr("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, &pf.style));
let mut props = Vec::new();
@@ -3163,6 +3129,90 @@ fn metadata_xmp(d: &Document) -> String {
)
}
/// The page's ruler guides, measured from the ruler origin written in the preferences.
fn push_page_guides(d: &Document, sp: &Spread, pg: &designcraft_doc::Page, pe: &mut El, page_index: usize) {
let facing = d.settings.facing_pages;
// Ruler guides, measured from the ruler origin written in the preferences: the
// spread's top left, the page's, or the spine (the import reads them the same way).
let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
let left = if left.is_finite() { left } else { 0.0 };
let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min);
let spine = if spine.is_finite() && facing { spine } else { 0.0 };
let origin_x = match d.settings.ruler_origin {
designcraft_doc::RulerOrigin::Spread => left,
designcraft_doc::RulerOrigin::Page => pg.x,
designcraft_doc::RulerOrigin::Spine => left + spine,
};
for g in &pg.guides {
let vertical = g.orientation == designcraft_doc::Orientation::Vertical;
let mut ge = El::new("Guide")
.attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count()))
.attr("Orientation", if vertical { "Vertical" } else { "Horizontal" })
.attr("Location", num(if vertical { g.position - origin_x } else { g.position }))
.attr("FitToPage", bool_s(!g.spread))
.attr("ViewThreshold", "5")
.attr("Locked", bool_s(g.locked))
.attr("PageIndex", page_index.to_string())
.attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" });
if let Some(l) = g.layer {
ge.set("ItemLayer", uid(l.0));
}
pe.push(ge);
}
}
/// A `MojikumiTable` element with its spacing overrides.
fn mojikumi_table_el(t: &designcraft_doc::cjk::MojikumiTable) -> El {
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
if !t.based_on.is_empty() {
e.set("BasedOnMojikumiSet", &t.based_on);
}
if !t.overrides.is_empty() {
let mut list = El::new("OverrideMojikumiAkiList");
for r in &t.overrides {
list.push(
El::new("OverrideMojikumiAkiType")
.attr("TargetMojikumiClass", r.target_class)
.attr("SideMojikumiClass", r.side_class)
.attr("SideIsAfterTarget", bool_s(r.after))
.attr("Minimum", num(r.minimum))
.attr("Desired", num(r.desired))
.attr("Maximum", num(r.maximum))
.attr("CompressionPriority", r.priority)
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
);
}
e.push(El::new("Properties").child(list));
}
e
}
/// A `Layer` element with its colour property.
fn layer_el(l: &designcraft_doc::Layer) -> El {
let color = match names::layer_color_out(l.color) {
Some(n) => p("LayerColor", "enumeration", n),
None => {
let mut e = El::new("LayerColor").attr("type", "list");
for c in l.color {
e.push(p("ListItem", "double", num(c as f64)));
}
e
}
};
let el = El::new("Layer")
.attr("Self", uid(l.id.0))
.attr("Name", &l.name)
.attr("Visible", bool_s(l.visible))
.attr("Locked", bool_s(l.locked))
.attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden))
.attr("ShowGuides", bool_s(l.show_guides))
.attr("LockGuides", "false")
.attr("UI", "true")
.attr("Expendable", "true")
.attr("Printable", bool_s(l.printable));
with_props(el, vec![color])
}
/// A text variable definition with its type's settings element.
fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El {
use designcraft_doc::vars::{Use, VarKind};
@@ -3304,6 +3354,10 @@ impl Ex<'_> {
if let Some(v) = a.kenten_overprint_stroke {
el.set("KentenOverprintStroke", crate::cjk::overprint_out(v));
}
self.shatai_chars(el, a);
}
fn shatai_chars(&self, el: &mut El, a: &CharAttrs) {
if let Some(v) = a.shatai_magnification {
el.set("ShataiMagnification", num(v));
}
+74 -35
View File
@@ -295,6 +295,10 @@ struct Importer<'r> {
parents: Vec<Spread>,
/// Things read but not kept as they were, for the import report.
warnings: Vec<String>,
/// Cells whose text is rotated (Cell Options › Text › Rotation): the angle is kept and
/// written back, but only 0 is composed, so a narrow header column of vertical labels comes
/// in horizontal and wraps mid-word (#1468).
rotated_cells: usize,
parent_ids: HashMap<String, SpreadId>,
spreads: Vec<Spread>,
page_index: HashMap<String, usize>,
@@ -387,13 +391,10 @@ fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
Ok(row)
}
/// The numbers in a whitespace-separated list; empty when any number is NaN or infinite (JSON
/// can't store them, and dropping just those would shift the rest).
fn nums(s: &str) -> Vec<f64> {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
/// Like [`nums`], but empty when any number is NaN or infinite.
fn finite_nums(s: &str) -> Vec<f64> {
let v = nums(s);
let v: Vec<f64> = s.split_whitespace().filter_map(|v| v.parse().ok()).collect();
if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
}
@@ -536,6 +537,7 @@ impl<'r> Importer<'r> {
story_ids: HashMap::new(),
parents: Vec::new(),
warnings: Vec::new(),
rotated_cells: 0,
parent_ids: HashMap::new(),
spreads: Vec::new(),
page_index: HashMap::new(),
@@ -791,6 +793,12 @@ impl<'r> Importer<'r> {
include_prefix: e.get("IncludeSectionPrefix") == Some("true"),
});
}
if self.rotated_cells > 0 {
let n = self.rotated_cells;
self.warnings.push(format!(
"{n} table cell(s) have rotated text (Cell Options › Text › Rotation): the angle is kept and written back, but the text is composed horizontally"
));
}
self.sections.sort_by_key(|s| s.start);
self.sections.dedup_by_key(|s| s.start);
if self.sections.first().is_none_or(|s| s.start != 0) {
@@ -895,6 +903,15 @@ impl<'r> Importer<'r> {
_ if v.len() >= 4 => Color::cmyk((v[0] / 100.0) as f32, (v[1] / 100.0) as f32, (v[2] / 100.0) as f32, (v[3] / 100.0) as f32),
_ => return None,
};
// Huge values (1e300) overflow `f32` to infinity.
let parts = match c {
Color::Rgb { r, g, b } => [r, g, b, 0.0],
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
};
if !parts.iter().all(|v| v.is_finite()) {
return None;
}
let ty = if e.get("Model") == Some("Spot") { ColorType::Spot } else { ColorType::Process };
Some((c, ty))
}
@@ -947,14 +964,14 @@ impl<'r> Importer<'r> {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
bands: finite_nums(e.get("StripeArray").unwrap_or(""))
bands: nums(e.get("StripeArray").unwrap_or(""))
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
.collect(),
},
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
"DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
@@ -1624,28 +1641,7 @@ impl<'r> Importer<'r> {
a.strikethrough_tint = tint;
}
}
// OpenType features (only when the element sets any).
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
read_otf_features(e, &mut a);
a.glyph_form = e.prop("GlyphForm");
a.no_break = e.boolean("NoBreak");
a.tate_chu_yoko = e.boolean("Tatechuyoko");
@@ -2170,9 +2166,12 @@ impl<'r> Importer<'r> {
}
// Table-local options override the named table style. Cell styles are already
// materialized independently, so their edges retain their higher precedence.
let mut border_unset = style.border.is_none() && style.borders.iter().all(|b| b.is_empty());
let mut borders = std::array::from_fn::<_, 4, _>(|i| t.options.border_for(i).clone());
for (side, border) in ["TopBorder", "LeftBorder", "BottomBorder", "RightBorder"].iter().zip(&mut borders) {
self.cell_stroke_attrs(e, side).apply_to(border);
let attrs = self.cell_stroke_attrs(e, side);
border_unset &= attrs.is_empty();
attrs.apply_to(border);
}
t.options.border = borders[0].clone();
t.options.borders = borders.map(Some);
@@ -2235,6 +2234,9 @@ impl<'r> Importer<'r> {
cell.first_baseline_min = min.clamp(0.0, 8640.0);
}
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
if cell.rotation != 0.0 {
self.rotated_cells += 1;
}
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
attrs.apply_to(&mut cell.strokes[i]);
@@ -2243,6 +2245,10 @@ impl<'r> Importer<'r> {
cell.stroke_priorities[i] = priority;
// Preserve style-defined edges and #102's positive-priority local overrides.
cell.border_overrides[i] |= priority > 0;
// No table or table style border is given anywhere, so the built-in 1 pt default must not
// draw over an outer edge the cell explicitly hides (weight 0 or the None swatch), as InDesign draws nothing.
let hidden = attrs.weight == Some(0.0) || attrs.color.as_deref() == Some(swatch::NONE);
cell.border_overrides[i] |= border_unset && hidden;
}
if let Some(slot) = t.cell_mut(r, c) {
*slot = cell;
@@ -2422,6 +2428,7 @@ impl<'r> Importer<'r> {
object_point: names::anchor_in(s("AnchorPoint")),
ref_point: row * 3 + col,
keep_within_column: s("PinPosition") == "true",
spine_relative: s("SpineRelative") == "true",
}
}
_ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") },
@@ -2856,7 +2863,7 @@ impl<'r> Importer<'r> {
{
it.fill.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
let start: Option<Vec<f64>> = e.prop("GradientFillStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
&& len > 0.0
{
@@ -2874,7 +2881,7 @@ impl<'r> Importer<'r> {
{
it.stroke.gradient_angle = Some(a);
}
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
let start: Option<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
&& len > 0.0
{
@@ -2967,8 +2974,10 @@ impl<'r> Importer<'r> {
if let Some(g) = self.with_style(e, "BaselineFrameGridOption")
&& g.get("UseCustomBaselineFrameGrid") == Some("true")
{
options.baseline_grid =
Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0)));
options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid {
start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0),
increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0),
});
}
it.content = Content::Text(TextFrame { story, options });
self.ctx.threads.push((
@@ -3702,6 +3711,11 @@ fn path_capped(pg: &El, max: usize) -> PathData {
let closed = g.get("PathOpen") != Some("true");
let mut anchors = Vec::new();
if let Some(arr) = g.find("PathPointArray") {
// A NaN or infinite point drops its subpath (JSON can't store it).
let non_finite = |s: Option<&str>| s.is_some_and(|s| s.split_whitespace().any(|v| v.parse::<f64>().is_ok_and(|x| !x.is_finite())));
if arr.find_all("PathPointType").take(left).any(|p| ["Anchor", "LeftDirection", "RightDirection"].iter().any(|k| non_finite(p.get(k)))) {
continue;
}
for p in arr.find_all("PathPointType").take(left) {
let Some(a) = parse_point(p.get("Anchor")) else { continue };
let hin = parse_point(p.get("LeftDirection")).unwrap_or(a);
@@ -3856,6 +3870,31 @@ impl Importer<'_> {
}
}
/// OpenType features (only when the element sets any).
fn read_otf_features(e: &El, a: &mut CharAttrs) {
let mut otf_list: Vec<String> = Vec::new();
let mut any = false;
for (tag, _, attr) in designcraft_doc::otf::TOGGLES {
if let Some(on) = e.boolean(attr) {
designcraft_doc::otf::set(&mut otf_list, tag, on);
any = true;
}
}
if let Some(fig) = e.prop("OTFFigureStyle")
&& let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim())
{
designcraft_doc::otf::set_figures(&mut otf_list, id);
any = true;
}
if let Some(m) = e.num("OTFStylisticSets") {
designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32);
any = true;
}
if any {
a.otf_features = Some(otf_list);
}
}
#[cfg(test)]
mod numeric_property_tests {
use super::*;
+24 -3
View File
@@ -144,16 +144,37 @@ pub const TABLE_BUILTINS: &[(&str, &str)] = &[("[Basic Table]", "$ID/[Basic Tabl
pub const OBJECT_BUILTINS: &[(&str, &str)] =
&[("[None]", "$ID/[None]"), ("[Basic Graphics Frame]", "$ID/[Normal Graphics Frame]"), ("[Basic Text Frame]", "$ID/[Normal Text Frame]")];
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`).
/// Our style name → IDML `Name` (groups: `A/B` → `A:B`; a literal `:` or `\` is escaped as `\:`
/// or `\\`, so it is not read back as a group separator).
pub fn style_name_out(builtins: &[(&str, &str)], name: &str) -> String {
builtins.iter().find(|(o, _)| *o == name).map(|(_, i)| i.to_string()).unwrap_or_else(|| name.replace('/', ":"))
builtins
.iter()
.find(|(o, _)| *o == name)
.map(|(_, i)| i.to_string())
.unwrap_or_else(|| name.replace('\\', "\\\\").replace(':', "\\:").replace('/', ":"))
}
/// IDML `Name` → our style name.
pub fn style_name_in(builtins: &[(&str, &str)], name: &str) -> String {
if let Some((o, _)) = builtins.iter().find(|(_, i)| *i == name) {
return o.to_string();
}
name.strip_prefix("$ID/").unwrap_or(name).replace(':', "/")
let name = name.strip_prefix("$ID/").unwrap_or(name);
let mut out = String::with_capacity(name.len());
let mut chars = name.chars();
while let Some(c) = chars.next() {
match c {
'\\' => match chars.clone().next() {
Some(next @ ('\\' | ':')) => {
out.push(next);
chars.next();
}
_ => out.push('\\'),
},
':' => out.push('/'),
_ => out.push(c),
}
}
out
}
/// `ParagraphStyle/…` Self id for one of our names.
pub fn style_self(kind: &str, builtins: &[(&str, &str)], name: &str) -> String {
+36 -31
View File
@@ -98,11 +98,45 @@ pub(crate) fn read(e: &El, a: &mut CharAttrs, mut swatch: impl FnMut(&str) -> St
a.ruby_overprint_stroke = overprint("RubyOverprintStroke");
}
/// Ruby parent-text options (OpenType Pro, auto tate-chu-yoko, overhang, spacing, scaling).
fn write_parent_options(el: &mut El, a: &CharAttrs) {
let b = |v: bool| if v { "true" } else { "false" };
if let Some(v) = a.ruby_open_type_pro {
el.set("RubyOpenTypePro", b(v));
}
if let Some(v) = a.ruby_auto_tcy_digits {
el.set("RubyAutoTcyDigits", v);
}
if let Some(v) = a.ruby_auto_tcy_include_roman {
el.set("RubyAutoTcyIncludeRoman", b(v));
}
if let Some(v) = a.ruby_auto_tcy_auto_scale {
el.set("RubyAutoTcyAutoScale", b(v));
}
if let Some(v) = a.ruby_overhang {
el.set("RubyOverhang", b(v));
}
if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) {
el.set("RubyParentOverhangAmount", v);
}
if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) {
el.set("RubyParentSpacing", v);
}
if let Some(v) = a.ruby_auto_align {
el.set("RubyAutoAlign", b(v));
}
if let Some(v) = a.ruby_auto_scaling {
el.set("RubyAutoScaling", b(v));
}
if let Some(v) = a.ruby_scaling_min {
el.set("RubyParentScalingPercent", num(v * 100.0));
}
}
/// Write the ruby settings set in `a`: attributes on `el`, fonts and colours as `props`. `sw`
/// maps a swatch name to its IDML reference. Returns the ruby font (family, style) to list in
/// the fonts part.
pub(crate) fn write(el: &mut El, props: &mut Vec<El>, a: &CharAttrs, sw: impl Fn(&str) -> String) -> Option<(String, String)> {
let b = |v: bool| if v { "true" } else { "false" };
let prop = |k: &str, ty: &str, v: String| El::new(k).attr("type", ty).text(v);
if let Some(v) = a.ruby_type.and_then(|v| name(TYPES, v)) {
el.set("RubyType", v);
@@ -134,36 +168,7 @@ pub(crate) fn write(el: &mut El, props: &mut Vec<El>, a: &CharAttrs, sw: impl Fn
if let Some(v) = a.ruby_y_scale {
el.set("RubyYScale", num(v * 100.0));
}
if let Some(v) = a.ruby_open_type_pro {
el.set("RubyOpenTypePro", b(v));
}
if let Some(v) = a.ruby_auto_tcy_digits {
el.set("RubyAutoTcyDigits", v);
}
if let Some(v) = a.ruby_auto_tcy_include_roman {
el.set("RubyAutoTcyIncludeRoman", b(v));
}
if let Some(v) = a.ruby_auto_tcy_auto_scale {
el.set("RubyAutoTcyAutoScale", b(v));
}
if let Some(v) = a.ruby_overhang {
el.set("RubyOverhang", b(v));
}
if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) {
el.set("RubyParentOverhangAmount", v);
}
if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) {
el.set("RubyParentSpacing", v);
}
if let Some(v) = a.ruby_auto_align {
el.set("RubyAutoAlign", b(v));
}
if let Some(v) = a.ruby_auto_scaling {
el.set("RubyAutoScaling", b(v));
}
if let Some(v) = a.ruby_scaling_min {
el.set("RubyParentScalingPercent", num(v * 100.0));
}
write_parent_options(el, a);
for (k, c) in [("RubyFill", &a.ruby_fill), ("RubyStroke", &a.ruby_stroke)] {
if let Some(c) = c {
props.push(if c.is_empty() { prop(k, "string", TEXT_COLOR.into()) } else { prop(k, "object", sw(c.as_str())) });
+211
View File
@@ -10,6 +10,8 @@ use super::*;
mod assets;
mod clipping;
mod decorations;
mod facing_ltr;
mod frame_grid;
mod index_sort;
mod liquid_guides;
mod object_styles;
@@ -1100,6 +1102,35 @@ fn imports_and_round_trips_explicit_cell_border_overrides() {
assert_eq!(roundtrip.cell(0, 1).unwrap().border_overrides, [false; 4]);
}
#[test]
fn explicitly_hidden_cell_edges_hide_the_default_table_border() {
// No table border or table style is given, and every cell edge is explicitly hidden (issue #1623):
// the first cell by weight 0, the second by the None swatch.
let cell = |name: &str, attr: &str, value: &str| {
format!(
r#"<Cell Name="{name}" LeftEdge{attr}="{value}" RightEdge{attr}="{value}" TopEdge{attr}="{value}" BottomEdge{attr}="{value}">
<ParagraphStyleRange><CharacterStyleRange><Content>X</Content></CharacterStyleRange></ParagraphStyleRange></Cell>"#
)
};
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange>
<Table HeaderRowCount="0" FooterRowCount="0">
<Row Name="0" MinimumHeight="24"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
{}{}
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#,
cell("0:0", "StrokeWeight", "0"),
cell("1:0", "StrokeColor", "Swatch/None")
);
let d = import_idml(&fixture_with_story(&story)).unwrap();
d.check().unwrap();
let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
for c in 0..2 {
assert_eq!(table.cell(0, c).unwrap().border_overrides, [true; 4], "cell {c} suppresses the table border");
}
}
#[test]
fn round_trips_footnotes_and_options() {
let mut d = Document::new(&NewDocument::default());
@@ -1325,6 +1356,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
object_point,
ref_point: 6,
keep_within_column,
spine_relative: false,
};
let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect();
assert_eq!(
@@ -1337,6 +1369,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() {
);
}
/// Relative to Spine comes from the object style chain like the other anchored settings, an
/// item's own setting wins, and export writes it back.
#[test]
fn anchored_spine_relative_comes_from_object_styles_and_round_trips() {
use designcraft_doc::AnchorPosition;
let styles = STYLES.replace(
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>"#,
r#"<ObjectStyle Self="ObjectStyle/$ID/[None]" Name="$ID/[None]"/>
<ObjectStyle Self="ObjectStyle/Side" Name="Side"><AnchoredObjectSetting SpineRelative="true"/></ObjectStyle>
<ObjectStyle Self="ObjectStyle/Note" Name="Note"><Properties><BasedOn type="object">ObjectStyle/Side</BasedOn></Properties></ObjectStyle>"#,
);
let rect = |style: &str, setting: &str| {
format!(
r#"<Rectangle Self="r{style}" AppliedObjectStyle="ObjectStyle/{style}" ItemTransform="1 0 0 1 0 0">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 10"/><PathPointType Anchor="10 10"/><PathPointType Anchor="10 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
<AnchoredObjectSetting AnchoredPosition="Anchored" {setting}/>
</Rectangle>"#
)
};
let story = format!(
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange><CharacterStyleRange><Content>a</Content>{}{}{}</CharacterStyleRange></ParagraphStyleRange>
</Story></idPkg:Story>"#,
rect("Note", ""),
rect("Note", r#"SpineRelative="false""#),
rect("$ID/[None]", ""),
);
let spine = |d: &Document| -> Vec<bool> {
let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap();
s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect()
};
let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap();
assert_eq!(spine(&d), [true, false, false]);
assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]);
}
/// IDML measures a left-aligned anchored object's X offset leftwards; the document model and
/// composition measure every X offset rightwards.
#[test]
@@ -4368,3 +4438,144 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_
let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
}
/// A graphic placed in a table cell is written into the cell, not dropped.
#[test]
fn exports_graphic_in_table_cell() {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Intro", ParaFormat::default()).unwrap();
let asset = designcraft_doc::AssetId(d.alloc());
let id = d.add_asset(designcraft_doc::Asset {
id: asset,
name: "tick.png".into(),
mime: "image/png".into(),
data: std::sync::Arc::new(vec![1, 2, 3]),
..Default::default()
});
let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
asset: id,
size: (24.0, 24.0),
xf: designcraft_geom::Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
d.story_mut(sid).unwrap().insert_table(5, t);
let story = zip_text(&export_idml(&d), "Stories/");
let cell = story.split("<Cell ").nth(3).unwrap_or_default();
let cell = cell.split("</Cell>").next().unwrap_or_default();
assert!(cell.contains("<Image "), "{story}");
assert!(cell.contains("ClipContentToCell=\"true\""), "{story}");
}
/// Non-finite numbers in colour values, page bounds and item transforms (NaN, infinities and
/// values like 1e300 that overflow `f32`) never reach the model: JSON would write them as null.
#[test]
fn non_finite_number_lists_never_reach_the_model() {
let graphic = GRAPHIC.replace(r#"ColorValue="255 0 0""#, r#"ColorValue="NaN 1e300 inf""#);
let spread = SPREAD
.replace(r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 700 500""#, r#"Self="p2" AppliedMaster="m1" GeometricBounds="0 0 NaN 500""#)
.replace(r#"ItemTransform="1 0 0 1 50 -300""#, r#"ItemTransform="1 0 0 1 inf -300""#);
let bytes = zip_files(&[
("designmap.xml", DESIGNMAP),
("Resources/Graphic.xml", &graphic),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", STORY),
]);
let doc = import_idml(&bytes).unwrap();
for s in &doc.swatches {
if let SwatchValue::Color { color, .. } | SwatchValue::Paper { color } = &s.value {
let parts: Vec<f32> = match *color {
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Gray { k } => vec![k],
};
assert!(parts.iter().all(|v| v.is_finite()), "swatch {} is not finite: {color:?}", s.name);
}
}
for sp in doc.spreads.iter().chain(&doc.parents) {
for p in &sp.pages {
assert!(p.width.is_finite() && p.height.is_finite() && p.x.is_finite(), "page {p:?}");
}
}
assert_eq!(doc.non_finite_item(), None);
}
/// Cell Options › Text › Rotation: the angle survives the round trip, and the import says it is
/// not composed — narrow header columns of vertical labels come in horizontal and wrap mid-word,
/// and nothing said so (#1468).
#[test]
fn rotated_cell_text_round_trips_and_the_import_says_it_is_not_composed() {
let mut d = Document::new(&NewDocument::default());
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
let mut t = designcraft_doc::Table::new(7, 1, 3, 0, 0, 300.0);
t.cell_mut(0, 0).unwrap().rotation = 90.0;
t.cell_mut(0, 1).unwrap().rotation = 270.0;
d.story_mut(sid).unwrap().insert_table(0, t);
let bytes = export_idml(&d);
let story = zip_text(&bytes, "Stories/");
assert!(story.contains(r#"RotationAngle="90""#) && story.contains(r#"RotationAngle="270""#), "{story}");
let back = import_idml_report(&bytes, &|_| None).unwrap();
let t = back.document.stories.values().find(|st| !st.tables.is_empty()).unwrap().tables.values().next().unwrap().clone();
assert_eq!((t.cell(0, 0).unwrap().rotation, t.cell(0, 1).unwrap().rotation, t.cell(0, 2).unwrap().rotation), (90.0, 270.0, 0.0));
let about: Vec<&String> = back.warnings.iter().filter(|w| w.contains("rotated text")).collect();
assert_eq!(about.len(), 1, "one line for the file, not one per cell: {:?}", back.warnings);
assert!(about[0].starts_with("2 table cell(s)"), "{}", about[0]);
assert!(about[0].contains("composed horizontally"), "{}", about[0]);
// A file with no rotated cell says nothing.
let mut plain = Document::new(&NewDocument::default());
let (_, sid) =
plain.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), plain.default_layer(), "", ParaFormat::default()).unwrap();
plain.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(8, 1, 2, 0, 0, 200.0));
let quiet = import_idml_report(&export_idml(&plain), &|_| None).unwrap();
assert!(quiet.warnings.iter().all(|w| !w.contains("rotated text")), "{:?}", quiet.warnings);
}
#[test]
fn style_names_with_a_literal_colon_survive_a_round_trip() {
use designcraft_doc::styles::{CellStyle, CharacterStyle, ObjectStyle, ParagraphStyle, TableStyle};
let names = ["Title: Big", "Group/Child: X", "Back\\slash: \\:"];
let mut d = small_doc();
for n in names {
let st = d.styles_mut();
st.paragraph.push(ParagraphStyle {
name: n.into(),
based_on: None,
next_style: None,
para: Default::default(),
chars: Default::default(),
shortcut: String::new(),
});
st.character.push(CharacterStyle { name: n.into(), based_on: None, chars: Default::default(), shortcut: String::new() });
st.cell.push(CellStyle { name: n.into(), ..Default::default() });
st.table.push(TableStyle { name: n.into(), ..Default::default() });
st.object.push(ObjectStyle { name: n.into(), ..Default::default() });
}
let back = import_idml(&export_idml(&d)).unwrap();
for n in names {
assert!(
back.styles.paragraph.iter().any(|s| s.name == n),
"paragraph {n:?}: {:?}",
back.styles.paragraph.iter().map(|s| &s.name).collect::<Vec<_>>()
);
assert!(back.styles.character.iter().any(|s| s.name == n), "character {n:?}");
assert!(back.styles.cell.iter().any(|s| s.name == n), "cell {n:?}");
assert!(back.styles.table.iter().any(|s| s.name == n), "table {n:?}");
assert!(back.styles.object.iter().any(|s| s.name == n), "object {n:?}");
}
// A group is still written as an IDML `:` group path.
assert_eq!(names::style_name_out(names::PARA_BUILTINS, "Group/Child"), "Group:Child");
for n in names {
assert_eq!(names::style_name_in(names::PARA_BUILTINS, &names::style_name_out(names::PARA_BUILTINS, n)), n);
}
}
+111
View File
@@ -0,0 +1,111 @@
use designcraft_doc::PageSide::{self, Left, Right};
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#;
/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top.
fn head(id: &str, story: &str, x0: f64, x1: f64) -> String {
let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
format!(
r#"<TextFrame Self="{id}" ParentStory="{story}" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
)
}
fn story(id: &str, text: &str) -> String {
format!(
r#"<idPkg:Story {NS}><Story Self="{id}"><ParagraphStyleRange><CharacterStyleRange><Content>{text}</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
)
}
/// Bound left to right with facing pages: the first page of a spread lies left of the spine
/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36,
/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its
/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure
/// from each page.
fn left_to_right_fixture() -> Vec<u8> {
let margins = r#"<MarginPreference ColumnCount="1" ColumnGutter="12" Top="36" Bottom="36" Left="36" Right="120"/>"#;
let page = |id: &str, x: f64, applied: bool, guide: Option<f64>| {
let applied = if applied { r#" AppliedMaster="m1""# } else { "" };
let guide = guide.map_or(String::new(), |at| format!(r#"<Guide Self="{id}g" Orientation="Vertical" Location="{at}"/>"#));
format!(r#"<Page Self="{id}"{applied} GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 {x} -300">{margins}{guide}</Page>"#)
};
let master = format!(
r#"<idPkg:MasterSpread {NS}><MasterSpread Self="m1" Name="A-Parent" NamePrefix="A" BaseName="Parent" PageCount="2" ItemTransform="1 0 0 1 0 0">{}{}{}{}</MasterSpread></idPkg:MasterSpread>"#,
page("mp1", -400.0, false, Some(108.0)),
page("mp2", 0.0, false, Some(300.0)),
head("hl", "sl", -280.0, -36.0),
head("hr", "sr", 36.0, 280.0),
);
let spread = |id: &str, pages: &[(&str, f64)]| {
let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect();
format!(r#"<idPkg:Spread {NS}><Spread Self="{id}" PageCount="{}" ItemTransform="1 0 0 1 0 0">{ps}</Spread></idPkg:Spread>"#, pages.len())
};
let map = format!(
r#"<Document {NS} Self="d" StoryList="sl sr"><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1"/><idPkg:MasterSpread src="MasterSpreads/m1.xml"/><idPkg:Spread src="Spreads/sp0.xml"/><idPkg:Spread src="Spreads/sp1.xml"/><idPkg:Story src="Stories/sl.xml"/><idPkg:Story src="Stories/sr.xml"/></Document>"#
);
let prefs = format!(
r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="true" PageBinding="LeftToRight"/><ViewPreference RulerOrigin="PageOrigin"/></idPkg:Preferences>"#
);
zip_files(&[
("designmap.xml", &map),
("Resources/Preferences.xml", &prefs),
("MasterSpreads/m1.xml", &master),
("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])),
("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])),
("Stories/sl.xml", &story("sl", "Left head")),
("Stories/sr.xml", &story("sr", "Right head")),
])
}
/// The text of the running heads a document page shows from its parent page.
fn shown_heads(d: &Document, abs: usize) -> Vec<String> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let parent = &d.parents[ppi];
parent
.items
.iter()
.filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage))
.filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone()))
.collect()
}
/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge.
fn parent_guides(d: &Document, abs: usize) -> Vec<f64> {
let (ppi, ppage) = d.parent_page_for(abs).unwrap();
let pp = &d.parents[ppi].pages[ppage];
pp.guides.iter().map(|g| g.position - pp.x).collect()
}
fn assert_right_pages_mirror_left_pages(d: &Document) {
assert!(d.settings.facing_pages && !d.settings.right_to_left_binding);
let sides: Vec<PageSide> = d.parents[0].pages.iter().map(|p| p.side).collect();
assert_eq!(sides, vec![Left, Right]);
// Right pages: inside (36) on the left; left pages: outside (120) on the left.
for (abs, side, live, guide, text) in [
(0, Right, (36.0, 280.0), 300.0, "Right head"),
(1, Left, (120.0, 364.0), 108.0, "Left head"),
(2, Right, (36.0, 280.0), 300.0, "Right head"),
] {
let n = abs + 1;
let page = d.page(abs).unwrap();
assert_eq!(page.side, side, "page {n}");
let m = page.margin_rect();
assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}");
assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page");
let guides = parent_guides(d, abs);
assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}");
assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}");
}
}
#[test]
fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() {
let d = import_idml(&left_to_right_fixture()).unwrap();
assert_right_pages_mirror_left_pages(&d);
let again = import_idml(&export_idml(&d)).unwrap();
assert_right_pages_mirror_left_pages(&again);
}
+71
View File
@@ -0,0 +1,71 @@
use super::*;
const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#;
/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a
/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so
/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from.
fn frame_grid_doc() -> Document {
let designmap = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document {NS} Self="d" StoryList="s1" Name="grid.indd"><idPkg:Graphic src="Resources/Graphic.xml"/><idPkg:Styles src="Resources/Styles.xml"/><idPkg:Preferences src="Resources/Preferences.xml"/><Layer Self="L1" Name="Layer 1" Visible="true" Locked="false"/><idPkg:Spread src="Spreads/Spread_sp1.xml"/><idPkg:Story src="Stories/Story_s1.xml"/></Document>"#
);
let prefs = format!(r#"<idPkg:Preferences {NS}><DocumentPreference PageWidth="400" PageHeight="600" FacingPages="false"/></idPkg:Preferences>"#);
let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)]
.iter()
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
.collect();
let spread = format!(
r#"<idPkg:Spread {NS}><Spread Self="sp1" PageCount="1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 600 400" ItemTransform="1 0 0 1 0 -300"/>
<TextFrame Self="t1" ParentStory="s1" PreviousTextFrame="n" NextTextFrame="n" ItemLayer="L1" ItemTransform="1 0 0 1 0 -300"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties><TextFramePreference InsetSpacing="0"/><BaselineFrameGridOption UseCustomBaselineFrameGrid="true" StartingOffsetForBaselineFrameGrid="6" BaselineFrameGridIncrement="14"/></TextFrame>
</Spread></idPkg:Spread>"#
);
let story = format!(
r#"<idPkg:Story {NS}><Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" GridAlignment="AlignToBaseline"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>One</Content><Br/><Content>Two</Content><Br/><Content>Three</Content><Br/><Content>Four</Content></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#
);
let bytes = zip_files(&[
("designmap.xml", &designmap),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", &prefs),
("Spreads/Spread_sp1.xml", &spread),
("Stories/Story_s1.xml", &story),
]);
import_idml_with(&bytes, &|_| None).unwrap()
}
/// Each composed line's baseline below the frame top.
fn baselines(d: &Document) -> Vec<f64> {
let sid = *d.stories.keys().next().unwrap();
let top = d.spreads[0].items[0].text_area().y0;
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect()
}
#[test]
fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() {
let d = frame_grid_doc();
let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid { start: 6.0, increment: 14.0 }));
let b = baselines(&d);
assert_eq!(b.len(), 4, "{b:?}");
for y in &b {
let n = (y - 6.0) / 14.0;
assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}");
}
// The first line's ascent is past the 6 pt line, so it sits on the next one.
assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn custom_frame_grid_round_trips() {
let d = frame_grid_doc();
let bytes = export_idml(&d);
let spread = zip_text(&bytes, "Spreads/");
assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}");
assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}");
let back = import_idml(&bytes).unwrap();
let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid;
assert_eq!(grid(&back), grid(&d));
assert_eq!(baselines(&back), baselines(&d));
}
+3 -3
View File
@@ -118,7 +118,7 @@ fn select_font() -> usvg::FontSelectionFn<'static> {
return Some(id);
}
}
// A family (or generic family) that matches: usvg's own selection.
// A family (or generic family) that matches: the query below.
_ => break,
}
}
@@ -738,8 +738,8 @@ mod tests {
impl<const BITS: u16> tiff::encoder::colortype::ColorType for Palette<BITS> {
type Inner = u8;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette;
const BITS_PER_SAMPLE: &'static [u16] = &[BITS];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint];
const BITS_PER_SAMPLE: &[u16] = &[BITS];
const SAMPLE_FORMAT: &[tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint];
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
result.extend_from_slice(row);
}
+12 -7
View File
@@ -81,6 +81,16 @@ impl Mode {
/// The merged composite as straight RGBA8, `None` when the file is damaged or unsupported (32-bit,
/// Multichannel, ZIP-compressed composite).
/// Whether the header's depth, channel count and size are all within what the decoder supports.
fn header_in_range(mode: Mode, depth: u16, channels: usize, (w, h): (u32, u32), max_side: u32) -> bool {
let depth_ok = match mode {
Mode::Bitmap => depth == 1,
Mode::Indexed => depth == 8,
_ => depth == 8 || depth == 16,
};
(1..=56).contains(&channels) && channels >= mode.colour_channels() && (1..=max_side).contains(&w) && (1..=max_side).contains(&h) && depth_ok
}
pub(crate) fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
let mut r = Reader { b: bytes };
if r.take(4)? != b"8BPS" {
@@ -97,13 +107,8 @@ pub(crate) fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
let depth = r.u16()?;
let mode = Mode::parse(r.u16()?)?;
let max_side = if psb { 300_000 } else { 30_000 };
let depth_ok = match mode {
Mode::Bitmap => depth == 1,
Mode::Indexed => depth == 8,
_ => depth == 8 || depth == 16,
};
let colours = mode.colour_channels();
if !(1..=56).contains(&channels) || channels < colours || !(1..=max_side).contains(&w) || !(1..=max_side).contains(&h) || !depth_ok {
if !header_in_range(mode, depth, channels, (w, h), max_side) {
return None;
}
let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
@@ -298,7 +303,7 @@ impl<'a> ImageData<'a> {
}
let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b)));
// This channel starts after every earlier channel's rows.
let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, |at, n| at.checked_add(n))?;
let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?;
for _ in 0..self.height {
let n = counts.next()?;
let end = at.checked_add(n)?;
+1
View File
@@ -0,0 +1 @@
mod rgba_conversion_contract;
@@ -3,8 +3,6 @@
//! goes inside the write root. Paths that leave a root (`..`, another folder, a sibling with the
//! same prefix, a link) are refused, nothing from a refused file reaches the document, and nothing
//! is written outside.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
+4
View File
@@ -0,0 +1,4 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+127 -38
View File
@@ -134,6 +134,25 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions)
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
}
/// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
/// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
/// Screen annotations aren't allowed in PDF/A.
fn add_form_fields(bytes: &mut Vec<u8>, warnings: &mut Vec<String>, doc: &Document, sheets: &[Sheet], opts: &PdfOptions) {
let fields = crate::forms::collect(doc, sheets);
let media = if opts.media { crate::forms::collect_media(doc, sheets) } else { Vec::new() };
if fields.is_empty() && media.is_empty() {
return;
}
match opts.standard {
Standard::PdfX4 => warnings.push("PDF/X-4: form fields and media were left out".into()),
Standard::PdfA2b => warnings.push("PDF/A-2b: form fields and media were left out".into()),
_ => match crate::forms::add_fields(&*bytes, &fields, &media) {
Some(b) => *bytes = b,
None => warnings.push("form fields and media couldn't be added to this PDF".into()),
},
}
}
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
let sheets = sheets(doc, opts)?;
let warnings = Vec::new();
@@ -235,23 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
// group that got past it, with its page.
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X
// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and
// Screen annotations aren't allowed in PDF/A.
let fields = crate::forms::collect(doc, &sheets);
let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() };
if !fields.is_empty() || !media.is_empty() {
if opts.standard == Standard::PdfX4 {
warnings.push("PDF/X-4: form fields and media were left out".into());
} else if opts.standard == Standard::PdfA2b {
warnings.push("PDF/A-2b: form fields and media were left out".into());
} else {
match crate::forms::add_fields(&bytes, &fields, &media) {
Some(b) => bytes = b,
None => warnings.push("form fields and media couldn't be added to this PDF".into()),
}
}
}
add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts);
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title, created) {
@@ -654,6 +657,27 @@ impl Exporter<'_> {
self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only))
}
/// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases
/// first, then the parent.
fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) {
let doc = self.doc;
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
}
}
fn spread(&mut self, s: &mut Surface, si: usize) {
let doc = self.doc;
let Some(sp) = doc.spreads.get(si) else { return };
@@ -663,24 +687,8 @@ impl Exporter<'_> {
continue;
}
for (pi, page) in sp.pages.iter().enumerate() {
if !page.show_parent_items {
continue;
}
// The parent's bases first, then the parent.
for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) {
let Some(parent) = doc.parents.get(ppi) else { continue };
let Some(ppg) = parent.pages.get(ppage) else { continue };
let dx = page.x - ppg.x;
for it in &parent.items {
if it.layer != layer.id || page.overridden.contains(&it.id) {
continue;
}
// Parent items belong to the parent page they sit on.
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
continue;
}
self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true);
}
if page.show_parent_items {
self.parent_items(s, si, pi, page, layer, first);
}
}
for it in &sp.items {
@@ -1629,6 +1637,21 @@ fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
/// Whether an ICC profile describes grey.
fn is_grey_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"GRAY".as_slice())
}
/// Whether `data` decodes to grey (with or without alpha), read from its header.
fn is_grey(data: &[u8]) -> bool {
use image::ImageDecoder;
image::ImageReader::new(std::io::Cursor::new(data))
.with_guessed_format()
.ok()
.and_then(|r| r.into_decoder().ok())
.is_some_and(|dec| !dec.color_type().has_color())
}
/// A JPEG, passed through. With `tag_rgb`, a JPEG keeps its own profile, and one without gets
/// sRGB. krilla uses a profile only when its channels match the JPEG's, so a grey or CMYK JPEG
/// without a profile stays DeviceGray or DeviceCMYK.
@@ -1668,6 +1691,10 @@ fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMo
_ => None,
};
direct.or_else(|| {
// A grey TIFF, BMP… stays DeviceGray, as a grey PNG or JPEG does (#1426).
if let Some(grey) = GreyImage::new(data) {
return Image::from_custom(grey, i).ok();
}
// TIFF, BMP, PSD composites… and, with `tag_rgb`, untagged RGB.
if mode.tag_rgb {
return SrgbImage::new(rgba).and_then(|img| Image::from_custom(img, i).ok());
@@ -1722,6 +1749,61 @@ impl krilla::image::CustomImage for SrgbImage {
}
}
/// A grey raster as a DeviceGray image (ICCBased when the file has a grey profile of its own), so
/// a page that only holds black prints as black, not as RGB.
#[derive(Clone, Hash)]
struct GreyImage {
grey: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
icc: Option<Arc<Vec<u8>>>,
}
impl GreyImage {
/// `None` when `data` isn't grey or can't be decoded.
fn new(data: &[u8]) -> Option<Self> {
if !is_grey(data) {
return None;
}
let img = image::load_from_memory(data).ok()?.to_luma_alpha8();
let size = img.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
if pixels == 0 {
return None;
}
let (mut grey, mut alpha) = (Vec::with_capacity(pixels), Vec::with_capacity(pixels));
for p in img.pixels() {
grey.push(p.0[0]);
alpha.push(p.0[1]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
let icc = embedded_icc(data).filter(|p| is_grey_icc(p)).map(Arc::new);
Some(Self { grey: Arc::new(grey), alpha, size, icc })
}
}
impl krilla::image::CustomImage for GreyImage {
fn color_channel(&self) -> &[u8] {
&self.grey
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.alpha.as_deref().map(Vec::as_slice)
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
self.size
}
fn icc_profile(&self) -> Option<&[u8]> {
self.icc.as_deref().map(Vec::as_slice)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Luma
}
}
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
@@ -1768,11 +1850,18 @@ fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
return None;
}
let rgb = img.to_rgb8();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
jpeg(buf.into(), || embedded_icc(data).filter(|p| is_rgb_icc(p)), mode)
// A grey image stays a grey JPEG (#1426).
let grey = !img.color().has_color();
if grey {
let l = img.to_luma8();
image::ImageEncoder::write_image(enc, l.as_raw(), l.width(), l.height(), image::ExtendedColorType::L8).ok()?;
} else {
let rgb = img.to_rgb8();
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
}
jpeg(buf.into(), || embedded_icc(data).filter(|p| if grey { is_grey_icc(p) } else { is_rgb_icc(p) }), mode)
}
#[cfg(test)]
+2 -2
View File
@@ -8,7 +8,7 @@ use std::fmt::Write as _;
use designcraft_doc::{Document, FieldKind, FormField, Item, MediaOptions};
use crate::export::Sheet;
use crate::transitions::{int_after, object, rfind};
use crate::transitions::{bracketed, int_after, object, rfind};
/// A field placed on an output page: (page index, PDF rectangle, field).
pub(crate) type Placed = (usize, [f64; 4], FormField);
@@ -94,7 +94,7 @@ pub(crate) fn add_fields(pdf: &[u8], fields: &[Placed], media: &[PlacedMedia]) -
let pages_id = int_after(&cat, "/Pages")?;
let pages = String::from_utf8_lossy(&pdf[object(pdf, pages_id)?]).to_string();
let kids_str = &pages[pages.find("/Kids")? + 5..];
let kids_str = &kids_str[kids_str.find('[')? + 1..kids_str.find(']')?];
let kids_str = bracketed(kids_str)?;
let nums: Vec<usize> = kids_str.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let kids: Vec<usize> = nums.chunks(2).map(|c| c[0]).collect();
+1 -1
View File
@@ -56,7 +56,7 @@ pub enum Standard {
impl Standard {
/// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`.
pub fn parse(s: &str) -> Option<Self> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"" | "none" => Some(Self::None),
"x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4),
+142 -17
View File
@@ -10,6 +10,8 @@ use hayro_syntax::page::Resources;
use crate::transitions::{find, int_after, object, rfind};
mod fonts;
/// Escape a PDF literal string.
fn lit(s: &str) -> String {
let mut o = String::from("(");
@@ -94,7 +96,7 @@ pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option<i64>) -> Option<Vec<u8>>
let s0 = find(body, b"stream", 0)? + b"stream".len();
let s0 = s0 + body[s0..].iter().take_while(|c| **c == b'\r' || **c == b'\n').count();
let s1 = rfind(body, b"endstream")?;
let xml = String::from_utf8_lossy(&body[s0..s1]).trim_end().to_string();
let xml = String::from_utf8_lossy(body.get(s0..s1)?).trim_end().to_string();
let dict = String::from_utf8_lossy(&body[..find(body, b"stream", 0)?]).to_string();
if dict.contains("/Filter") {
return None;
@@ -246,6 +248,7 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
issues.push("Type 3 fonts aren't allowed".into());
}
issues.extend(device_rgb(pdf));
issues.extend(fonts::unembedded_fonts(pdf));
issues
}
@@ -282,7 +285,7 @@ fn device_rgb(pdf: &[u8]) -> Vec<String> {
found
.into_iter()
.map(|(what, pages)| {
let list: Vec<String> = pages.iter().take(10).map(|p| p.to_string()).collect();
let list: Vec<String> = pages.iter().take(10).map(ToString::to_string).collect();
let more = if pages.len() > 10 { ", …" } else { "" };
let s = if pages.len() == 1 { "" } else { "s" };
format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", "))
@@ -389,18 +392,7 @@ impl RgbScan {
}
b"BI" | b"EI" if !managed => {
if let Some(Object::Stream(img)) = last {
let cs = img.dict().get::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(b"ColorSpace"));
let cs = match cs {
Some(Object::Name(n))
if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") =>
{
res.get_color_space(&n)
}
cs => cs,
};
if cs.is_some_and(|cs| is_device_rgb(&cs)) {
self.add("an RGB inline image".into());
}
self.inline_image(&img, res);
}
}
_ => {}
@@ -408,6 +400,20 @@ impl RgbScan {
}
}
/// An inline image (`BI … EI`) in an RGB space, named directly or through the resources.
fn inline_image(&mut self, img: &Stream<'_>, res: &Resources<'_>) {
let cs = img.dict().get::<Object<'_>>(b"CS").or_else(|| img.dict().get::<Object<'_>>(b"ColorSpace"));
let cs = match cs {
Some(Object::Name(n)) if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => {
res.get_color_space(&n)
}
cs => cs,
};
if cs.is_some_and(|cs| is_device_rgb(&cs)) {
self.add("an RGB inline image".into());
}
}
fn image(&mut self, img: &Stream<'_>, managed: bool) {
let id = img.obj_id();
if managed || !self.seen.insert(id) || img.dict().get::<bool>(b"ImageMask") == Some(true) {
@@ -480,7 +486,7 @@ mod tests {
use crate::{ExportReport, PdfOptions, Standard, check_pdfx4, export_pdf_with_report};
fn place(d: &mut Document, name: &str, data: Vec<u8>, size: (f64, f64), at: (f64, f64)) {
pub(super) fn place(d: &mut Document, name: &str, data: Vec<u8>, size: (f64, f64), at: (f64, f64)) {
let asset = AssetId(d.alloc());
let mime = designcraft_images::mime(&data).into();
let pixels = designcraft_images::pixel_size(&data);
@@ -579,7 +585,7 @@ mod tests {
pdf.finish().unwrap()
}
fn x4(d: &Document) -> ExportReport {
pub(super) fn x4(d: &Document) -> ExportReport {
export_pdf_with_report(d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap()
}
@@ -645,13 +651,18 @@ mod tests {
/// A minimal PDF with a CMYK (N 4) PDF/X output intent and one page with `entries` (its
/// resources, a group…) and `content`.
fn tiny_pdf(entries: &str, content: &str) -> Vec<u8> {
tiny_pdf_with(entries, content, "<</N 3/Length 0>>\nstream\n\nendstream")
}
/// [`tiny_pdf`] with `extra` as object 6 (an RGB profile in [`tiny_pdf`]).
pub(super) fn tiny_pdf_with(entries: &str, content: &str, extra: &str) -> Vec<u8> {
let objects = [
"<</Type/Catalog/Pages 2 0 R/OutputIntents[<</Type/OutputIntent/S/GTS_PDFX/DestOutputProfile 4 0 R>>]>>".to_string(),
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
format!("<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]{entries}/Contents 5 0 R>>"),
"<</N 4/Length 0>>\nstream\n\nendstream".to_string(),
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
"<</N 3/Length 0>>\nstream\n\nendstream".to_string(),
extra.to_string(),
];
let mut out = b"%PDF-1.6\n".to_vec();
let mut offsets = Vec::new();
@@ -668,6 +679,18 @@ mod tests {
out
}
/// A metadata object whose only `stream` is the one inside `endstream` is refused, not a slice panic.
#[test]
fn metadata_with_only_endstream_does_not_panic() {
let objects = ["<</Type/Catalog/Pages 2 0 R/Metadata 3 0 R>>", "<</Type/Pages/Kids[]/Count 0>>", "<</Type/Metadata>>endstream"];
let mut out = b"%PDF-1.6\n".to_vec();
for (i, o) in objects.iter().enumerate() {
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
out.extend_from_slice(b"trailer\n<</Size 4/Root 1 0 R>>\nstartxref\n0\n%%EOF\n");
assert!(super::make_pdfx4(&out, "t", None).is_none());
}
/// DeviceRGB is allowed when a `DefaultRGB` colour space makes it colour-managed.
#[test]
fn default_rgb_makes_device_rgb_managed() {
@@ -682,6 +705,108 @@ mod tests {
assert!(!cmyk.iter().any(rgb), "{cmyk:?}");
}
/// Every font that draws visible text must be embedded (#909): a base-14 font with no font
/// file, a CID font whose descendant has none, and one drawn from inside a form are reported;
/// an embedded font, an unused one and invisible text (render mode 3, as in OCR layers) are not.
#[test]
fn unembedded_fonts_are_flagged() {
let fonts = |entries: &str, content: &str| -> Vec<String> {
check_pdfx4(&tiny_pdf(entries, content)).into_iter().filter(|i| i.contains("font")).collect()
};
let helvetica = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
assert_eq!(
fonts(helvetica, "BT /F1 24 Tf 20 50 Td (Base14 text) Tj ET"),
["the font Helvetica isn't embedded (page 1): PDF/X-4 needs every font used for visible text embedded"]
);
assert!(fonts(helvetica, "BT /F1 24 Tf 3 Tr 20 50 Td (OCR) Tj ET").is_empty(), "invisible text needs no embedded font");
assert!(fonts(helvetica, "0 0 0 1 k 0 0 10 10 re f").is_empty(), "an unused font is not reported");
let restored = fonts(helvetica, "BT /F1 24 Tf q 3 Tr Q 20 50 Td (shown) Tj ET");
assert_eq!(restored.len(), 1, "Q restores the visible render mode: {restored:?}");
let embedded = "/Resources<</Font<</F1<</Type/Font/Subtype/TrueType/BaseFont/ABCDEF+Arial/FontDescriptor<</Type/FontDescriptor/FontName/ABCDEF+Arial/FontFile2 6 0 R>>>>>>>>";
assert!(fonts(embedded, "BT /F1 12 Tf (x) Tj ET").is_empty());
let cid = "/Resources<</Font<</F1<</Type/Font/Subtype/Type0/BaseFont/KozMin/Encoding/Identity-H/DescendantFonts[<</Type/Font/Subtype/CIDFontType0/BaseFont/KozMin/FontDescriptor<</Type/FontDescriptor/FontName/KozMin>>>>]>>>>>>";
assert_eq!(fonts(cid, "BT /F1 12 Tf <0001> Tj ET").len(), 1);
let cid_embedded = cid.replace("/FontName/KozMin>>", "/FontName/KozMin/FontFile3 6 0 R>>");
assert!(fonts(&cid_embedded, "BT /F1 12 Tf <0001> Tj ET").is_empty());
let draw = "BT /T 9 Tf (in a form) Tj ET";
let form = format!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</T<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/Length {}>>\nstream\n{draw}\nendstream",
draw.len()
);
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/X1 Do", &form);
let issues: Vec<String> = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("font")).collect();
assert_eq!(issues.len(), 1, "{issues:?}");
assert!(issues[0].starts_with("the font Times-Roman isn't embedded"), "{issues:?}");
}
/// A placed PDF that draws with an unembedded font: the PDF/X-4 export warns about it (#909).
#[test]
fn placed_pdf_with_an_unembedded_font_is_reported() {
let mut d = Document::new(&NewDocument::default());
let placed = tiny_pdf(
"/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>>>>>",
"BT /F1 24 Tf 0 0 0 1 k 20 50 Td (Base14 text) Tj ET",
);
place(&mut d, "helvetica.pdf", placed, (100.0, 100.0), (72.0, 72.0));
let r = x4(&d);
assert!(r.warnings.iter().any(|w| w.starts_with("PDF/X-4: the font Helvetica isn't embedded (page 1)")), "{:?}", r.warnings);
}
#[test]
fn unembedded_fonts_are_reported_by_every_text_show_operator() {
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>";
for show in ["(x) Tj", "[(x) 20 (y)] TJ", "(x) '", "0 0 (x) \""] {
let pdf = tiny_pdf(entries, &format!("BT /F1 12 Tf {show} ET"));
let issues = check_pdfx4(&pdf);
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{show}: {issues:?}");
}
}
#[test]
fn unembedded_fonts_in_forms_inherit_and_restore_text_state() {
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
let draw = "BT (inherited font) Tj /F1 10 Tf 3 Tr ET";
let form = format!("<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Length {}>>\nstream\n{draw}\nendstream", draw.len());
for (content, count) in [
("/F1 12 Tf /X1 Do", 1),
("/F1 12 Tf 3 Tr /X1 Do", 0),
("/F1 12 Tf 3 Tr /X1 Do 0 Tr /X1 Do", 1),
("/F1 12 Tf /X1 Do BT (still visible) Tj ET", 1),
] {
let pdf = tiny_pdf_with(entries, content, &form);
let issues = check_pdfx4(&pdf);
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), count, "{content}: {issues:?}");
}
}
#[test]
fn unembedded_fonts_in_recursive_forms_are_bounded() {
let draw = "BT /F1 12 Tf (x) Tj ET /X1 Do";
let form = format!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>/Length {}>>\nstream\n{draw}\nendstream",
draw.len()
);
let pdf = tiny_pdf_with("/Resources<</XObject<</X1 6 0 R>>>>", "/X1 Do", &form);
let issues = check_pdfx4(&pdf);
assert_eq!(issues.iter().filter(|i| i.contains("isn't embedded")).count(), 1, "{issues:?}");
}
#[test]
fn unembedded_fonts_are_reported_after_form_text_state_is_restored() {
let entries = "/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>/XObject<</X1 6 0 R>>>>";
let draw = "BT /F2 10 Tf 3 Tr (hidden) Tj ET";
let form = format!(
"<</Type/XObject/Subtype/Form/BBox[0 0 100 100]/Resources<</Font<</F2<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>>>>>/Length {}>>\nstream\n{draw}\nendstream",
draw.len()
);
let pdf = tiny_pdf_with(entries, "/F1 12 Tf /X1 Do BT (visible) Tj ET", &form);
let issues: Vec<String> = check_pdfx4(&pdf).into_iter().filter(|i| i.contains("isn't embedded")).collect();
assert_eq!(issues.len(), 1, "{issues:?}");
assert!(issues[0].starts_with("the font Helvetica isn't embedded"), "{issues:?}");
}
/// An RGB blending space on the page group and an RGB inline image are device RGB too.
#[test]
fn rgb_groups_and_inline_images_are_flagged() {
+172
View File
@@ -0,0 +1,172 @@
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use hayro_syntax::content::UntypedIter;
use hayro_syntax::object::{Array, Dict, Name, Object, ObjectIdentifier};
use hayro_syntax::page::Resources;
use super::MAX_DEPTH;
#[cfg(test)]
mod tests;
const MAX_OPERATIONS: usize = 100_000;
const LIMIT_WARNING: &str = "font scan operation limit reached: PDF/X-4 font embedding checks are incomplete";
const DEPTH_WARNING: &str = "font scan nesting depth limit reached: PDF/X-4 font embedding checks are incomplete";
pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec<String> {
let Ok(file) = hayro_syntax::Pdf::new(pdf.to_vec()) else { return Vec::new() };
let mut found: BTreeMap<String, Vec<usize>> = BTreeMap::new();
let mut scan = FontScan::default();
for (i, page) in file.pages().iter().enumerate() {
scan.font = None;
scan.mode = 0;
if let Some(content) = page.page_stream() {
scan.content(content, page.resources(), 0);
}
for name in std::mem::take(&mut scan.found) {
found.entry(name).or_default().push(i + 1);
}
if scan.limited {
break;
}
}
let mut warnings: Vec<String> = found
.into_iter()
.map(|(name, pages)| {
let list: Vec<String> = pages.iter().take(10).map(ToString::to_string).collect();
let more = if pages.len() > 10 { ", …" } else { "" };
let s = if pages.len() == 1 { "" } else { "s" };
format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", "))
})
.collect();
if scan.limited {
warnings.push(LIMIT_WARNING.to_string());
}
if scan.depth_limited {
warnings.push(DEPTH_WARNING.to_string());
}
warnings
}
fn font_embedded(font: &Dict<'_>) -> bool {
let descriptor = match font.get::<Name<'_>>(b"Subtype").as_deref() {
Some(b"Type3") => return true,
Some(b"Type0") => {
font.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).and_then(|d| d.get::<Dict<'_>>(b"FontDescriptor"))
}
_ => font.get::<Dict<'_>>(b"FontDescriptor"),
};
descriptor.is_some_and(|d| d.contains_key(b"FontFile") || d.contains_key(b"FontFile2") || d.contains_key(b"FontFile3"))
}
#[derive(Default)]
struct FontScan<'a> {
found: BTreeSet<String>,
active: HashSet<ObjectIdentifier>,
path: Vec<ObjectIdentifier>,
completed: HashMap<FormContext<'a>, BTreeSet<String>>,
font: Option<Dict<'a>>,
mode: i32,
operations: usize,
limited: bool,
// A form nested too deeply to follow. The scan goes on (unlike `limited`), and the flag is
// never cleared, so a memoized subtree that was cut short can't hide it.
depth_limited: bool,
}
// Forms inherit text state and missing resource names. Depth and ancestors also affect
// which descendants the depth/cycle guards permit; only identical contexts may share results.
#[derive(Hash, PartialEq, Eq)]
struct FormContext<'a> {
form: ObjectIdentifier,
font: Option<&'a [u8]>,
mode: i32,
resources: Vec<(&'a [u8], &'a [u8])>,
ancestors: Vec<ObjectIdentifier>,
}
impl<'a> FontScan<'a> {
fn content(&mut self, data: &[u8], res: &Resources<'a>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
let mut saved = Vec::new();
let mut ops = UntypedIter::new(data);
while let Some(op) = ops.next() {
if self.operations == MAX_OPERATIONS {
self.limited = true;
return;
}
self.operations += 1;
let operator: &[u8] = op.operator;
match operator {
b"q" => saved.push((self.font.clone(), self.mode)),
b"Q" => {
if let Some(state) = saved.pop() {
(self.font, self.mode) = state;
}
}
b"Tf" => self.font = op.operands().next().cloned().and_then(Object::into_name).and_then(|n| res.get_font(&n)),
b"Tr" => {
if let Some(mode) = op.operands().next().cloned().and_then(|n| i32::try_from(n).ok()) {
self.mode = mode;
}
}
b"Tj" | b"TJ" | b"'" | b"\"" if self.mode != 3 => {
if let Some(font) = self.font.as_ref().filter(|f| !font_embedded(f)) {
let name = font.get::<Name<'_>>(b"BaseFont").map_or_else(|| "(unnamed)".to_string(), |n| n.as_str().to_string());
self.found.insert(name);
}
}
b"Do" => {
let xo = op.operands().last().cloned().and_then(Object::into_name).and_then(|n| res.get_x_object(&n));
if let Some(form) = xo.filter(|x| x.dict().get::<Name<'_>>(b"Subtype").as_deref() == Some(b"Form"))
&& (depth < MAX_DEPTH || {
// A form already being scanned is a cycle: it can't hide new content.
self.depth_limited |= !self.active.contains(&form.obj_id());
false
})
&& self.active.insert(form.obj_id())
{
let mut resources = Vec::new();
let mut parent = Some(res);
while let Some(r) = parent {
resources.push((r.fonts.data(), r.x_objects.data()));
parent = r.parent();
}
let key = FormContext {
form: form.obj_id(),
font: self.font.as_ref().map(Dict::data),
mode: self.mode,
resources,
ancestors: self.path.clone(),
};
if let Some(found) = self.completed.get(&key) {
self.found.extend(found.iter().cloned());
self.active.remove(&form.obj_id());
continue;
}
let inner = Resources::from_parent(form.dict().get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
let state = (self.font.clone(), self.mode);
let outer_found = std::mem::take(&mut self.found);
self.path.push(form.obj_id());
if let Ok(data) = form.decoded() {
self.content(&data, &inner, depth + 1);
}
self.path.pop();
if !self.limited {
self.completed.insert(key, self.found.clone());
}
self.found.extend(outer_found);
(self.font, self.mode) = state;
self.active.remove(&form.obj_id());
if self.limited {
return;
}
}
}
_ => {}
}
}
}
}

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