mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Merge remote-tracking branch 'origin/main' into codex/toc-dialog-title-width
# Conflicts: # ROADMAP.md # docs/ui-parity.md
This commit is contained in:
+23
-2
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(j, &snaps, ¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(j, &snaps, ¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(j, &snaps, ¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(
|
||||
key.1,
|
||||
&snaps,
|
||||
¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(j, &snaps, ¬e_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, ¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(
|
||||
j,
|
||||
&snaps,
|
||||
¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(
|
||||
j,
|
||||
&snaps,
|
||||
¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(
|
||||
j,
|
||||
&snaps,
|
||||
¬e_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, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
|
||||
rewind(
|
||||
key.2,
|
||||
&snaps,
|
||||
¬e_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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", ¶ms).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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms, 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use designcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -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");
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
+34
-26
@@ -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)))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mod export_tag_styles;
|
||||
mod grouped_reading_order;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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())) });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user