Merge fix/idml-interchange: $ID/ font names, placed PDF page and crop, packaged IDML links (#338, #339, #340)

Conflict: ROADMAP.md: next's IDML interchange line with the branch's sentence on placed PDFs, $ID/ font names and packaged links appended; next's INDD line kept
Conflict: crates/engine/src/tests_idml.rs: next's drop cap test and the branch's two placed PDF tests
Conflict: crates/fonts/src/fontdb.rs: next's plausible/measured_top/typo_ascent and the branch's plain_family; next's canonical_family lookups, with plain_family in place of without_format_suffix in style_traits, face (two) and has_family; the branch's has_exact_family dropped (has_family uses canonical_family)
Conflict: crates/idml/src/import.rs: next's char_attrs (tab-split family and composite font names), falling back to names::font_family_in
This commit is contained in:
aiongg
2026-10-10 16:20:17 +00:00
33 changed files with 705 additions and 112 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows.
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). Placed PDFs keep their page (`PDFAttribute`), font names with the `$ID/` prefix resolve, and the IDML in a package links the images in its Links folder without embedding them.
- INDD import through indd-utils (#114): File › Open, drop, `file.open`/`file.openBytes` and the CLI open InDesign documents and templates (.indd, .indt) by converting them to IDML in memory and importing that; the converter's warnings join the open result's `warnings`.
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
@@ -110,7 +110,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box.
- Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`.
- Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle).
- Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows.
- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet.
+1 -2
View File
@@ -183,13 +183,12 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
d.assets.insert(
id,
Arc::new(Asset {
page: 0,
id,
name: format!("art-{k}.png"),
mime: "image/png".into(),
link: None,
data: Arc::new(data),
pixels: Some((aw, ah)),
..Default::default()
}),
);
id
+63 -1
View File
@@ -447,7 +447,7 @@ pub const LAYER_COLORS: &[(&str, [u8; 3])] = &[
];
/// An embedded file (placed image / PDF). Bytes are stored next to the JSON in the native format.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Asset {
pub id: AssetId,
@@ -464,6 +464,68 @@ pub struct Asset {
/// The page shown from a multi-page PDF (0-based; Image Import Options).
#[serde(default, skip_serializing_if = "is_zero")]
pub page: u32,
/// Image Import Options › Crop to: the page box a placed PDF shows.
#[serde(default, skip_serializing_if = "PdfCrop::is_crop")]
pub pdf_crop: PdfCrop,
/// Where the `pdf_crop` box sits on the page as rendered (its visible crop box, rotated as it
/// displays): left, top, right, bottom as fractions of the page's width and height. Graphic
/// space (0,0)-(w,h) spans this box. `None`: the whole page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf_box: Option<[f64; 4]>,
}
impl Asset {
/// The part of the page a placed PDF shows, when [`Asset::pdf_box`] holds a usable box.
pub fn shown_box(&self) -> Option<[f64; 4]> {
let b = self.pdf_box.filter(|_| self.mime == "application/pdf")?;
// A box under 1/10000 of the page, or far outside it, would blow the page up to sizes
// nothing can draw.
let usable = b.iter().all(|v| v.is_finite() && v.abs() <= 1e3) && b[2] - b[0] >= 1e-4 && b[3] - b[1] >= 1e-4;
usable.then_some(b)
}
}
/// Image Import Options › Crop to: which box of a PDF page a placed PDF shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PdfCrop {
/// The crop box: the page as PDF readers show it.
#[default]
Crop,
Art,
Trim,
Bleed,
Media,
/// The bounds of what the page draws, with the layers the PDF shows by default.
ContentVisible,
/// The bounds of what the page draws, with every layer on.
ContentAll,
}
impl PdfCrop {
pub const ALL: [PdfCrop; 7] =
[PdfCrop::ContentVisible, PdfCrop::ContentAll, PdfCrop::Crop, PdfCrop::Art, PdfCrop::Trim, PdfCrop::Bleed, PdfCrop::Media];
/// The name commands and files use (`crop`, `trim`, `contentVisible`…).
pub fn name(self) -> &'static str {
match self {
PdfCrop::Crop => "crop",
PdfCrop::Art => "art",
PdfCrop::Trim => "trim",
PdfCrop::Bleed => "bleed",
PdfCrop::Media => "media",
PdfCrop::ContentVisible => "contentVisible",
PdfCrop::ContentAll => "contentAll",
}
}
pub fn from_name(name: &str) -> Option<PdfCrop> {
PdfCrop::ALL.into_iter().find(|c| c.name() == name)
}
fn is_crop(&self) -> bool {
*self == PdfCrop::Crop
}
}
fn is_zero(v: &u32) -> bool {
+15
View File
@@ -296,3 +296,18 @@ fn next_id_near_the_top_is_rejected() {
d.next_id = crate::MAX_NEXT_ID;
d.check().unwrap();
}
/// A placed PDF saved before its crop choice was stored loads with the crop box (the whole page),
/// and that default isn't written out.
#[test]
fn placed_pdf_without_a_crop_choice_loads_with_the_crop_box() {
let a: Asset = serde_json::from_str(r#"{"id": 7, "name": "a.pdf", "mime": "application/pdf", "page": 1}"#).unwrap();
assert_eq!((a.pdf_crop, a.pdf_box, a.shown_box()), (PdfCrop::Crop, None, None));
let json = serde_json::to_string(&a).unwrap();
assert!(!json.contains("pdfCrop") && !json.contains("pdfBox"), "{json}");
let trim = Asset { pdf_crop: PdfCrop::Trim, pdf_box: Some([0.1, 0.1, 0.9, 0.9]), ..a };
let back: Asset = serde_json::from_str(&serde_json::to_string(&trim).unwrap()).unwrap();
assert_eq!((back.pdf_crop, back.shown_box()), (PdfCrop::Trim, Some([0.1, 0.1, 0.9, 0.9])));
// A box with no area shows the whole page.
assert_eq!(Asset { pdf_box: Some([0.5, 0.5, 0.5, 0.9]), ..back }.shown_box(), None);
}
+3 -1
View File
@@ -45,7 +45,9 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
vec![]
};
if path.to_lowercase().ends_with(".idml") {
return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
let mut d = designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
super::interchange::resolve_pdf_crops(&mut d);
return Ok(d);
}
super::file::from_bytes(&bytes)
}
+1 -1
View File
@@ -367,7 +367,7 @@ fn place_image(
link,
data: Arc::new(bytes),
pixels: Some((pw, ph)),
page: 0,
..Default::default()
}),
);
let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] };
+9 -8
View File
@@ -249,13 +249,12 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
out.assets.insert(
aid,
std::sync::Arc::new(Asset {
page: 0,
id: aid,
name: format!("flattened-{}.png", aid.0),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(img.to_png()),
pixels: Some((w, h)),
..Default::default()
}),
);
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
@@ -415,13 +414,12 @@ fn rasterize_where(
out.assets.insert(
aid,
std::sync::Arc::new(Asset {
page: 0,
id: aid,
name: format!("effects-{}.png", id.0),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(img.to_png()),
pixels: Some((w, h)),
..Default::default()
}),
);
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
@@ -461,20 +459,23 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> (Option<designcraft_doc::
if !designcraft_render::is_pdf(&a.data) {
continue;
}
let side = (g.size.0.max(g.size.1) * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden) else { continue };
// The whole page at 300 ppi (the graphic may show only a box of it).
let shown = a.shown_box();
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
let Some(png) = designcraft_render::pdf_page_png(&a.data, a.page as usize, side, &hidden, shown) else { continue };
let px = designcraft_render::image_size(&png);
let aid = designcraft_doc::AssetId(out.alloc());
out.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name: format!("{}.png", a.name),
mime: "image/png".into(),
link: None,
data: std::sync::Arc::new(png),
pixels: px,
..Default::default()
}),
);
if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) {
+104 -22
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use designcraft_doc::build::{NewDocument, PRESETS};
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Selection, Shape};
use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, PdfCrop, Selection, Shape};
use designcraft_geom::{Affine, Rect, shapes};
use serde_json::{Value, json};
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+D"),
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|trim|bleed|art|media, layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}",
"{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?, pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), layoutPage?: n (IDML / .designcraft: that page's objects as a group)} — places an image (into the selected empty frame if any); text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect` — {autoflow?: adds pages with threaded frames until the text fits, removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename}",
has_doc,
file_place
),
@@ -399,16 +399,23 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
};
// 72 ppi by default unless the file says otherwise; scale so it fits the page when huge.
let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64));
// Image Import Options › Crop to: the frame shows that box of the PDF page.
let crop_box = match str_param(p, "pdfCrop") {
Some(k) if designcraft_render::is_pdf(&bytes) => {
if !["crop", "trim", "bleed", "art", "media"].contains(&k) {
return Err(bad("file.place", format!("unknown pdfCrop `{k}` (crop, trim, bleed, art, media)")));
}
designcraft_render::pdf_page_box(&bytes, pdf_page as usize, k).filter(|b| b.2 > 0.0 && b.3 > 0.0)
// Image Import Options › Crop to: the graphic spans that box of the PDF page.
let is_pdf = designcraft_render::is_pdf(&bytes);
let pdf_crop = match str_param(p, "pdfCrop") {
Some(k) if is_pdf => PdfCrop::from_name(k)
.ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?,
_ => PdfCrop::Crop,
};
let mime = designcraft_render::image_mime(&bytes).to_string();
let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() };
// The graphic's natural size: the box in points (the whole page when the box can't be found,
// and then the document says so).
let (bw, bh) = match crop_pdf(&mut asset) {
Some(size) => size,
None => {
asset.pdf_crop = PdfCrop::Crop;
(nw, nh)
}
Some(_) => None,
None => None,
};
let target_frame = super::id_param(p, "frame").or_else(|| {
let st = s.active()?;
@@ -421,8 +428,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let want_w = p.get("width").and_then(Value::as_f64);
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) }));
d.assets.insert(aid, Arc::new(Asset { id: aid, ..asset }));
// The width the whole page (or image) gets; the height follows the proportions.
let w = match want_w {
Some(w) => w,
@@ -435,11 +441,11 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
let r = it.inner_bounds();
// Fill frame proportionally.
let k = (r.width() / nw).max(r.height() / nh);
let (gw, gh) = (nw * k, nh * k);
let k = (r.width() / bw).max(r.height() / bh);
let (gw, gh) = (bw * k, bh * k);
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
size: (bw, bh),
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
auto_fit: designcraft_doc::Fitting::FillProportionally,
fit_align: 4,
@@ -452,13 +458,12 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0));
let id = ItemId(d.alloc());
let k = w / nw;
let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh));
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + bw * k, y + bh * k)));
it.object_style = d.styles.default_graphic_frame.clone();
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
xf: Affine::translate((x - bx * k, y - by * k)) * Affine::scale(k),
size: (bw, bh),
xf: Affine::translate((x, y)) * Affine::scale(k),
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
@@ -508,6 +513,19 @@ pub fn base64_decode(s: &str) -> Vec<u8> {
out
}
/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in
/// points. The crop box is the whole page and has none; nor has a page that can't be read, which
/// keeps its crop choice (a missing link relinked later gets its box then).
pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> {
a.pdf_box = None;
if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) {
return None;
}
let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?;
a.pdf_box = Some(frac);
Some(size)
}
pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)> {
if let Some(b) = str_param(p, "base64") {
return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None));
@@ -529,7 +547,15 @@ fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: name.clone(), mime, link: link.clone(), data: Arc::new(bytes), pixels: Some((pw, ph)) }),
Arc::new(Asset {
id: aid,
name: name.clone(),
mime,
link: link.clone(),
data: Arc::new(bytes),
pixels: Some((pw, ph)),
..Default::default()
}),
);
Ok(aid)
})?;
@@ -616,7 +642,9 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
const ID: &str = "file.place";
let src = if name.to_lowercase().ends_with(".idml") {
designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))?
let mut d = designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))?;
super::interchange::resolve_pdf_crops(&mut d);
d
} else {
from_bytes(bytes)?
};
@@ -722,7 +750,9 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
{
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
let mut d = designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))?;
super::interchange::resolve_pdf_crops(&mut d);
d
} else {
from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)?
};
@@ -833,6 +863,58 @@ mod pdf_crop_tests {
assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}");
assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err());
}
/// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it,
/// even in a frame larger than the graphic.
#[test]
fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() {
// Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap();
for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] {
let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone();
s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap();
}
let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
// `width` is the whole page's (630 pt with its bleed): half size.
let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
let d = t.doc().unwrap().doc.clone();
let it = d.item(id).unwrap();
let g = it.graphic().unwrap().clone();
let a = &d.assets[&g.asset];
assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim);
let b = a.shown_box().unwrap();
let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0];
assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}");
assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size);
let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1));
let fb = it.bounds();
for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] {
assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}");
}
// A frame larger than the graphic still shows only the trim box.
t.edit(|d, _| {
d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0));
Ok(())
})
.unwrap();
let d = t.doc().unwrap().doc.clone();
let mut rr = designcraft_render::Renderer::new();
rr.threads = 0;
let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap();
let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60;
// The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows
// where it is on the page.
assert!(!dark(97, 97), "the bleed is outside the trim box");
assert!(!dark(103, 103), "the page is offset by its bleed");
assert!(dark(110, 110), "the square inside the trim box");
}
}
#[cfg(test)]
+48 -1
View File
@@ -50,7 +50,8 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
None
}
};
let document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
let mut document = designcraft_idml::import_idml_with(bytes, &read).map_err(|e| EngineError::Other(e.to_string()))?;
resolve_pdf_crops(&mut document);
#[cfg(not(target_arch = "wasm32"))]
let document = {
let mut document = document;
@@ -66,6 +67,52 @@ pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
Ok(document)
}
/// Placed PDFs read from IDML name their crop (`PDFCrop`): find where each box sits on its page,
/// which the IDML reader can't (it doesn't parse PDFs). The box spans the graphic's
/// `GraphicBounds`; a graphic without bounds takes the box's size.
pub(crate) fn resolve_pdf_crops(d: &mut Document) {
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_doc::{AssetId, Content, Item, PdfCrop};
let mut sizes: HashMap<AssetId, (f64, f64)> = HashMap::new();
for a in d.assets.values_mut() {
if a.pdf_crop == PdfCrop::Crop || a.pdf_box.is_some() {
continue;
}
let a = Arc::make_mut(a);
if let Some(size) = super::file::crop_pdf(a) {
sizes.insert(a.id, size);
}
}
let no_size = |i: &Item| matches!(&i.content, Content::Graphic(g) if !(g.size.0 > 0.0 && g.size.1 > 0.0) && sizes.contains_key(&g.asset));
fn fix(it: &mut Arc<Item>, sizes: &HashMap<AssetId, (f64, f64)>, no_size: &dyn Fn(&Item) -> bool) {
let mut any = false;
it.walk(&mut |i| any |= no_size(i));
if !any {
return;
}
let it = Arc::make_mut(it);
if let Content::Graphic(g) = &mut it.content
&& let Some(size) = sizes.get(&g.asset)
&& !(g.size.0 > 0.0 && g.size.1 > 0.0)
{
g.size = *size;
}
for k in it.children_mut().into_iter().flatten() {
fix(k, sizes, no_size);
}
}
if sizes.is_empty() {
return;
}
for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) {
for it in &mut Arc::make_mut(sp).items {
fix(it, &sizes, &no_size);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_PATH_BYTES: usize = 128 * 1024;
+6 -2
View File
@@ -15,7 +15,7 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, uses: [{id, page, ppi}]}]", has_doc, |s, _| {
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}]", has_doc, |s, _| {
Ok(Value::Array(list(&s.doc()?.doc)))
}),
cmd!(
@@ -136,7 +136,8 @@ fn list(d: &Document) -> Vec<Value> {
json!({"id": id.0, "page": page, "ppi": ppi})
})
.collect();
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "uses": uses})
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
})
.collect()
}
@@ -166,6 +167,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
a.pixels = Some(px);
a.name = name.clone();
a.link = Some(path.clone());
// A placed PDF keeps its crop choice; its box is found on the new file.
super::file::crop_pdf(a);
Ok(json!({"name": name}))
})
}
@@ -229,6 +232,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let a = Arc::make_mut(a);
a.data = Arc::new(bytes.clone());
a.pixels = Some(*px);
super::file::crop_pdf(a);
}
}
Ok(json!({"updated": n}))
+2 -4
View File
@@ -14,10 +14,8 @@ use crate::{Result, Session};
fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item {
let (svg, (w, h)) = crate::math::to_svg(latex, size);
let aid = AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }),
);
d.assets
.insert(aid, Arc::new(Asset { id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), data: Arc::new(svg), ..Default::default() }));
let id = ItemId(d.alloc());
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
+5 -2
View File
@@ -26,7 +26,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
let mime = mime.to_string();
s.edit(|d, sel| {
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: None }));
d.assets.insert(aid, Arc::new(Asset { id: aid, name, mime, link, data: Arc::new(bytes), ..Default::default() }));
let id = match target {
Some(fid) => {
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
@@ -92,7 +92,10 @@ pub fn specs() -> Vec<CommandSpec> {
Some(Some((bytes, name, link, px))) => {
let aid = AssetId(d.alloc());
let mime = designcraft_render::image_mime(&bytes).to_string();
d.assets.insert(aid, Arc::new(Asset { page: 0, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px) }));
d.assets.insert(
aid,
Arc::new(Asset { id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some(px), ..Default::default() }),
);
Some(Some(aid))
}
Some(None) => Some(None),
+5
View File
@@ -1611,6 +1611,11 @@ fn clipping_path(s: &mut Session, p: &Value) -> Result<Value> {
let Some(g) = it.graphic() else { continue };
let Some(asset) = d.assets.get(&g.asset) else { continue };
let Some(px) = designcraft_render::decode_pixmap_page(&asset.data, asset.page) else { continue };
// A placed PDF cropped to a box: trace that box, which graphic space spans.
let px = match asset.shown_box() {
Some(frac) => designcraft_render::crop_pixmap(&px, frac).unwrap_or(px),
None => px,
};
let (pw, ph) = (px.width() as usize, px.height() as usize);
// A grid of at most 400 cells on the long side.
let step = (pw.max(ph) as f64 / 400.0).max(1.0);
+36 -3
View File
@@ -197,7 +197,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Package…",
["File"],
None,
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy, an optional PDF and a report → {dir, files, report}",
"{dir: folder to create, idml?: true, pdf?: false, instructions?: text} — the document (its placed files relinked to Links/), Links/, the font files that draw its text in Document Fonts/ (fallback fonts included; unless their licence restricts it), an IDML copy (linking the files in Links/, nothing embedded), an optional PDF and a report → {dir, files, report}",
has_doc,
package
),
@@ -394,7 +394,9 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
.collect();
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
let path = package_output_path(&dir, &title, "idml")?;
root.write(&path, &designcraft_idml::export_idml(&packed))?;
// The images are in Links/ beside it: link them there, without embedded copies.
let opts = designcraft_idml::ExportOptions { embed_images: false };
root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?;
files.push(path);
}
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
@@ -464,7 +466,7 @@ mod tests {
let mut opened = Session::new();
opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap();
let asset = opened.doc().unwrap().doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png, "{extension}: preserve embedded pixels");
assert_eq!(*asset.data, png, "{extension}: the packaged pixels");
assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file");
let links = opened.execute("links.list", &json!({})).unwrap();
assert_eq!(links[0]["status"], "ok", "{extension}");
@@ -472,6 +474,37 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// The package's IDML links its images to the files in the package's Links folder and embeds
/// no copies of them.
#[test]
fn package_idml_links_the_links_folder_and_embeds_nothing() {
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id()));
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
let mut s = Session::new();
s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap();
s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap();
s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap();
let mut spreads = String::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
if f.name().starts_with("Spreads/") {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
}
}
let linked = dir.join("Links").join("my photo.png");
let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20"));
assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}");
assert!(!spreads.contains("<Contents") && !spreads.contains(r#"StoredState="Embedded""#), "no image is embedded");
let back = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap();
let asset = back.assets.values().next().unwrap();
assert!(asset.data.is_empty(), "no image is embedded");
assert_eq!(asset.link.as_deref(), linked.to_str());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn package_writes_document_links_and_report() {
let dir = std::env::temp_dir().join(format!("dc-package-{}", std::process::id()));
+1 -1
View File
@@ -1407,13 +1407,13 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
d.assets.insert(
aid,
std::sync::Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name,
mime,
link,
data: std::sync::Arc::new(bytes),
pixels: Some((pw, ph)),
..Default::default()
}),
);
let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?;
+1 -1
View File
@@ -67,7 +67,7 @@ fn image(d: &mut Document, name: &str, w: u32, h: u32, hue: f32) -> AssetId {
let data = art_png(w, h, hue);
d.assets.insert(
id,
Arc::new(Asset { page: 0, id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(data), pixels: Some((w, h)) }),
Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), data: Arc::new(data), pixels: Some((w, h)), ..Default::default() }),
);
id
}
+106
View File
@@ -364,3 +364,109 @@ fn type_para_sets_a_drop_cap_that_survives_idml() {
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}
/// IDML says which page of a placed PDF shows and which box its graphic spans, so InDesign opens
/// it at the same size and position; importing it back gives the same page, crop and geometry.
#[test]
fn placed_pdf_page_and_crop_round_trip_through_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap();
let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
// Page 2, cropped to its trim box: the frame is smaller than the page with its bleed.
let place = json!({"base64": b64, "name": "a.pdf", "pdfPage": 2, "pdfCrop": "trim", "width": 315, "x": 20, "y": 30});
let r = t.execute("file.place", &place).unwrap();
let d = t.doc().unwrap().doc.clone();
let item = d.item(ItemId(r["id"].as_u64().unwrap())).unwrap();
let (bounds, g) = (item.bounds(), item.graphic().unwrap().clone());
assert_eq!(d.assets[&g.asset].page, 1);
assert_eq!(d.assets[&g.asset].pdf_crop, designcraft_doc::PdfCrop::Trim);
let bytes = designcraft_idml::export_idml(&d);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut spreads = String::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
if f.name().starts_with("Spreads/") {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
}
}
let attr = spreads.split("<PDFAttribute ").nth(1).and_then(|a| a.split("/>").next()).expect("a PDFAttribute");
assert!(attr.contains(r#"PageNumber="2""#) && attr.contains(r#"PDFCrop="CropTrim""#), "{attr}");
assert!(spreads.contains(r#"<GraphicBounds Left="0" Top="0" Right="612" Bottom="792"/>"#), "the trim box");
let back = crate::cmd::interchange::import(&bytes, None).unwrap();
let bi = back.spreads.iter().flat_map(|sp| sp.items.iter()).find(|i| i.graphic().is_some()).unwrap();
let bg = bi.graphic().unwrap();
let ba = &back.assets[&bg.asset];
assert_eq!((ba.page, ba.pdf_crop), (1, designcraft_doc::PdfCrop::Trim));
let (want, got) = (d.assets[&g.asset].shown_box().unwrap(), ba.shown_box().expect("the trim box found on the page"));
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?} vs {want:?}");
let bb = bi.bounds();
assert!([bb.x0 - bounds.x0, bb.y0 - bounds.y0, bb.x1 - bounds.x1, bb.y1 - bounds.y1].iter().all(|v| v.abs() < 1e-3), "{bb:?} vs {bounds:?}");
assert!((bg.size.0 - g.size.0).abs() < 1e-3 && (bg.size.1 - g.size.1).abs() < 1e-3);
let (a, b) = ((bi.xf * bg.xf).as_coeffs(), (item.xf * g.xf).as_coeffs());
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3), "{a:?} vs {b:?}");
// A page the PDF doesn't have imports, and the PDF export leaves it out with a warning.
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let mut body = String::new();
std::io::Read::read_to_string(&mut f, &mut body).unwrap();
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut out, body.replace(r#"PageNumber="2""#, r#"PageNumber="4294967295""#).as_bytes()).unwrap();
}
let hostile = designcraft_idml::import_idml(&out.finish().unwrap().into_inner()).unwrap();
assert_eq!(hostile.assets.values().map(|a| a.page).collect::<Vec<_>>(), [u32::MAX - 1]);
t.edit(|doc, _| {
*doc = hostile;
Ok(())
})
.unwrap();
let r = t.execute("file.exportPdf", &json!({})).unwrap();
assert!(r["warnings"].to_string().contains("has no page 4294967295"), "{r}");
}
/// An IDML placed PDF cropped to its media box, without `GraphicBounds`: the graphic takes the
/// media box (the page with its 9 pt bleed).
#[test]
fn idml_placed_pdf_cropped_to_its_media_box_gets_media_box_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap();
let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string();
let mut t = Session::new();
t.execute("file.new", &json!({})).unwrap();
t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 630, "x": 0, "y": 0})).unwrap();
let bytes = designcraft_idml::export_idml(&t.doc().unwrap().doc);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut edited = false;
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let mut body = Vec::new();
std::io::Read::read_to_end(&mut f, &mut body).unwrap();
if f.name().starts_with("Spreads/") {
let text = String::from_utf8(body).unwrap().replace(r#"PDFCrop="CropTrim""#, r#"PDFCrop="CropMedia""#);
let start = text.find("<GraphicBounds ").unwrap();
let end = start + text[start..].find("/>").unwrap() + 2;
body = format!("{}{}", &text[..start], &text[end..]).into_bytes();
edited = true;
}
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut out, &body).unwrap();
}
assert!(edited);
let back = crate::cmd::interchange::import(&out.finish().unwrap().into_inner(), None).unwrap();
let g = back.spreads.iter().flat_map(|sp| sp.items.iter()).find_map(|i| i.graphic().cloned()).unwrap();
let a = &back.assets[&g.asset];
assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Media);
assert!((g.size.0 - 630.0).abs() < 1e-3 && (g.size.1 - 810.0).abs() < 1e-3, "{:?}", g.size);
let b = a.shown_box().unwrap_or([0.0, 0.0, 1.0, 1.0]);
assert!(b.iter().zip([0.0, 0.0, 1.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-6), "{b:?}");
}
+1 -2
View File
@@ -832,13 +832,12 @@ mod tests {
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "Cover & Notes".into(),
mime: "image/png; profile=screen".into(),
link: None,
data: Arc::new(vec![1]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
+13 -6
View File
@@ -806,6 +806,12 @@ fn typo_ascent(f: &skrifa::FontRef<'_>, location: &Location, upem: f64) -> Optio
v += mvar.metric_delta(skrifa::raw::tables::mvar::tags::HASC, location.coords()).map_or(0.0, |d| d.to_f64());
}
plausible(Some(v), upem)
/// `family` without what layout apps add to a family name, if it has any: InDesign's `$ID/`
/// prefix (`$ID/Arial` → `Arial`) and the font-format suffix appended when a family is installed
/// in several formats (`Minion Pro (OTF)` → `Minion Pro`).
fn plain_family(family: &str) -> Option<&str> {
let unprefixed = family.trim_start().strip_prefix("$ID/").map(str::trim).filter(|f| !f.is_empty());
without_format_suffix(unprefixed.unwrap_or(family)).or(unprefixed)
}
/// `family` without the font-format suffix layout apps append when a family is installed in
@@ -1491,7 +1497,7 @@ impl ScopedFonts<'_> {
/// Regular first, then by weight.
fn style_traits(&self, family: &str) -> Vec<(bool, f32, String)> {
let v = self.exact_style_traits(family);
match without_format_suffix(family) {
match plain_family(family) {
Some(base) if v.is_empty() => self.exact_style_traits(base),
_ => v,
}
@@ -1520,12 +1526,13 @@ impl ScopedFonts<'_> {
/// Resolve a family + style to a face, falling back to the closest style of the family, then to
/// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. A
/// family with a format suffix (`Minion Pro (OTF)`) that isn't found is looked up without it.
/// family with InDesign's `$ID/` prefix or a format suffix (`Minion Pro (OTF)`) that isn't
/// found is looked up without them.
pub fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
let canonical = self
.canonical_family(family)
.or_else(|| without_format_suffix(family).and_then(|base| self.canonical_family(base)))
.unwrap_or_else(|| self.substitute_family(without_format_suffix(family).unwrap_or(family)));
.or_else(|| plain_family(family).and_then(|base| self.canonical_family(base)))
.unwrap_or_else(|| self.substitute_family(plain_family(family).unwrap_or(family)));
if let Some(f) = self.find_or_load(&canonical, style) {
return f;
}
@@ -1547,9 +1554,9 @@ impl ScopedFonts<'_> {
}
/// Is `family` available (the document's, loaded, or installed on the system), as named or
/// without a format suffix (see [`ScopedFonts::face`])?
/// without an `$ID/` prefix or a format suffix (see [`ScopedFonts::face`])?
pub fn has_family(&self, family: &str) -> bool {
self.canonical_family(family).is_some() || without_format_suffix(family).is_some_and(|base| self.canonical_family(base).is_some())
self.canonical_family(family).is_some() || plain_family(family).is_some_and(|base| self.canonical_family(base).is_some())
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
+14
View File
@@ -453,6 +453,20 @@ mod tests {
assert!(!db.has_family("DC Test Suffix"));
}
/// IDML files from InDesign can name a family with its `$ID/` prefix (`$ID/Arial`).
#[test]
fn an_indesign_id_prefix_names_the_plain_family() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let prefixed = format!("$ID/{DEFAULT_FAMILY}");
assert_eq!(db.face(&prefixed, "Bold").family, DEFAULT_FAMILY);
assert!(db.has_family(&prefixed));
assert_eq!(db.styles(&prefixed), db.styles(DEFAULT_FAMILY));
assert!(db.has_family(&format!("$ID/{DEFAULT_FAMILY} (OTF)")));
assert!(!db.has_family("$ID/"));
assert!(!db.has_family("$ID/DC No Such Family"));
}
#[test]
fn outlines_and_metrics() {
let db = FontDb::global();
+1 -2
View File
@@ -264,13 +264,12 @@ mod tests {
d.assets.insert(
aid,
Arc::new(Asset {
page: 0,
id: aid,
name: "x.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(vec![1, 2, 3, 4]),
pixels: Some((1, 1)),
..Default::default()
}),
);
let bytes = save(&d).unwrap();
+14 -1
View File
@@ -1809,7 +1809,9 @@ impl<'a> Ex<'a> {
fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El {
let is_pdf = asset.mime == "application/pdf";
let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh());
if let Some((pw, ph)) = asset.pixels
// A PDF is vector: its page size in `pixels` isn't a resolution, and a cropped graphic
// spans less than the page.
if let Some((pw, ph)) = asset.pixels.filter(|_| !is_pdf)
&& g.size.0 > 0.0
&& g.size.1 > 0.0
{
@@ -1848,6 +1850,17 @@ impl<'a> Ex<'a> {
.attr("ImportPolicy", "NoAutoImport")
.attr("ExportPolicy", "NoAutoExport"),
);
if is_pdf {
// `GraphicBounds` span the chosen box, as graphic space does. Without `PDFAttribute`
// the page would be cropped to its visible content, which changes the graphic's size
// and so its scale and position.
el.push(
El::new("PDFAttribute")
.attr("PageNumber", asset.page.saturating_add(1))
.attr("PDFCrop", names::pdf_crop_out(asset.pdf_crop))
.attr("TransparentBackground", "true"),
);
}
el
}
+12 -5
View File
@@ -365,7 +365,7 @@ impl<'r> Importer<'r> {
.map(|c| designcraft_doc::cjk::CompositeFontEntry {
name: c.get("Name").unwrap_or("").into(),
characters: c.get("CustomCharacters").unwrap_or("").into(),
family: c.prop("AppliedFont").unwrap_or_default(),
family: c.prop("AppliedFont").as_deref().and_then(names::font_family_in).unwrap_or_default(),
style: c.get("FontStyle").unwrap_or("Regular").trim_start_matches("$ID/").into(),
relative_size: c.num("RelativeSize").unwrap_or(100.0) / 100.0,
horizontal_scale: c.num("HorizontalScale").unwrap_or(100.0) / 100.0,
@@ -1223,9 +1223,7 @@ impl<'r> Importer<'r> {
let f = f.trim();
// Some writers append the style after a tab.
let fam = f.split('\t').next().unwrap_or(f);
if !fam.is_empty() && fam != "$ID/" {
a.font_family = Some(self.composite_names.get(fam).cloned().unwrap_or_else(|| fam.to_string()));
}
a.font_family = self.composite_names.get(fam).cloned().or_else(|| names::font_family_in(fam));
}
a.font_style = e.prop("FontStyle");
a.size = e.num("PointSize");
@@ -2416,7 +2414,11 @@ impl<'r> Importer<'r> {
uri.as_deref().and_then(|p| p.rsplit(['/', '\\']).next()).filter(|n| !n.is_empty()).map(str::to_string).unwrap_or_else(|| "image".into());
let id = AssetId(self.alloc());
let link_path = uri.filter(|p| p.contains('/') || p.contains('\\'));
self.assets.insert(id, Arc::new(Asset { page: 0, id, name, mime, link: link_path, data: Arc::new(data), pixels }));
let page = pdf_page_in(g);
// Where the box sits on the page needs the PDF parsed: the engine finds it after import.
let pdf_crop = g.find("PDFAttribute").and_then(|a| a.get("PDFCrop")).map_or(designcraft_doc::PdfCrop::Crop, names::pdf_crop_in);
self.assets
.insert(id, Arc::new(Asset { page, id, name, mime, link: link_path, data: Arc::new(data), pixels, pdf_crop, ..Default::default() }));
Content::Graphic(designcraft_doc::Graphic {
asset: id,
size,
@@ -2819,6 +2821,11 @@ fn path_of(pg: &El) -> PathData {
PathData::new(subs)
}
/// The page a placed PDF shows (0-based), from its `PDFAttribute` (`PageNumber` is 1-based).
fn pdf_page_in(g: &El) -> u32 {
g.find("PDFAttribute").and_then(|a| a.get("PageNumber")).and_then(|v| v.trim().parse::<u32>().ok()).map_or(0, |n| n.saturating_sub(1))
}
/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`).
pub(crate) fn uri_to_path(uri: &str) -> String {
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
+7
View File
@@ -35,6 +35,13 @@
//! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and
//! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available),
//! clipping paths, compound-path fill rules.
//! - Placed PDFs: the page (`PageNumber`) and crop choice (`PDFCrop`) round-trip, and
//! `GraphicBounds` span the chosen box. Where that box sits on the page needs the PDF parsed,
//! which this crate doesn't do: the engine finds it after import (`file.openIdml`), and when the
//! PDF's data isn't available the page's crop box fills `GraphicBounds`. The content crops
//! (`CropContentVisibleLayers`, `CropContentAllLayers`) are the bounds of the page's
//! non-transparent pixels on a render about 2048 pixels on its long side, so they can differ
//! from a vector bounding box by a fraction of a point.
//! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+29
View File
@@ -44,6 +44,26 @@ pub fn anchor_rel_in(s: &str) -> designcraft_doc::anchored::AnchorRelative {
}
}
/// `PDFAttribute` `PDFCrop` for a placed PDF's crop choice.
pub fn pdf_crop_out(c: designcraft_doc::PdfCrop) -> &'static str {
use designcraft_doc::PdfCrop as C;
match c {
C::Crop => "CropPDF",
C::Art => "CropArt",
C::Trim => "CropTrim",
C::Bleed => "CropBleed",
C::Media => "CropMedia",
C::ContentVisible => "CropContentVisibleLayers",
C::ContentAll => "CropContentAllLayers",
}
}
/// A placed PDF's crop choice from `PDFCrop` (the crop box when unknown).
pub fn pdf_crop_in(s: &str) -> designcraft_doc::PdfCrop {
use designcraft_doc::PdfCrop as C;
C::ALL.into_iter().find(|c| pdf_crop_out(*c) == s).unwrap_or(C::Crop)
}
pub fn anchor_out(i: u8) -> &'static str {
ANCHORS[(i as usize).min(8)]
}
@@ -81,6 +101,15 @@ pub fn unescape_id(s: &str) -> String {
s.replace("%3a", ":").replace("%3A", ":").replace("%25", "%")
}
/// An `AppliedFont` value → font family. InDesign can write the family with its `$ID/` prefix
/// (`$ID/Arial`), and `$ID/` alone for no font; some writers append the style after a tab.
pub fn font_family_in(v: &str) -> Option<String> {
let v = v.trim();
let family = v.split('\t').next().unwrap_or(v);
let family = family.strip_prefix("$ID/").unwrap_or(family).trim();
(!family.is_empty()).then(|| family.to_string())
}
/// Built-in style names: (ours, IDML `Name`).
pub const PARA_BUILTINS: &[(&str, &str)] = &[("[No Paragraph Style]", "$ID/[No paragraph style]"), ("[Basic Paragraph]", "$ID/NormalParagraphStyle")];
pub const CHAR_BUILTINS: &[(&str, &str)] = &[("[None]", "$ID/[No character style]")];
+39 -1
View File
@@ -1290,7 +1290,7 @@ fn cjk_composite_fonts_and_custom_kinsoku_are_document_resources() {
let map = DESIGNMAP.replace("</Document>", r#"
<KinsokuTable Self="KinsokuTable/Test" Name="Test" CantBeginLineChars="乙" CantEndLineChars="甲" CantBeSeparatedChars="—" HangingPunctuationChars="。"/>
<CompositeFont Self="CompositeFont/Mixed" Name="Mixed"><CompositeFontEntry Self="cf1" Name="Base" FontStyle="Regular"><Properties><AppliedFont type="string">Source Serif 4</AppliedFont></Properties></CompositeFontEntry>
<CompositeFontEntry Self="cf2" Name="Digits" CustomCharacters="0123456789" FontStyle="Regular" RelativeSize="80" BaselineShift="10"><Properties><AppliedFont type="string">Source Sans 3</AppliedFont></Properties></CompositeFontEntry></CompositeFont>
<CompositeFontEntry Self="cf2" Name="Digits" CustomCharacters="0123456789" FontStyle="Regular" RelativeSize="80" BaselineShift="10"><Properties><AppliedFont type="string">$ID/Source Sans 3</AppliedFont></Properties></CompositeFontEntry></CompositeFont>
</Document>"#);
let story = STORY.replace("<ParagraphStyleRange ", "<ParagraphStyleRange KinsokuSet=\"KinsokuTable/Test\" ");
let bytes = zip_files(&[
@@ -2224,3 +2224,41 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() {
check(&d);
check(&import_idml(&export_idml(&d)).unwrap());
}
/// InDesign can write `AppliedFont` with its `$ID/` prefix (`$ID/Source Serif 4`). The text is
/// set in that family instead of counting as a missing font drawn in the fallback.
#[test]
fn applied_font_with_an_id_prefix_names_the_family() {
let styles = STYLES.replace(">Source Serif 4</AppliedFont>", ">$ID/Source Serif 4</AppliedFont>");
let story = STORY.replace(
r#"<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/Strong" PointSize="14">"#,
r#"<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/Strong" PointSize="14"><Properties><AppliedFont type="string">$ID/Source Serif 4</AppliedFont></Properties>"#,
);
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 d = import_idml_with(&bytes, &|_| None).unwrap();
let fonts: Vec<&str> = d.styles.paragraph.iter().filter_map(|s| s.chars.font_family.as_deref()).collect();
assert!(!fonts.is_empty() && fonts.iter().all(|f| *f == "Source Serif 4"), "{fonts:?}");
let (sid, story) = d.stories.iter().next().unwrap();
let bold = story.runs().find(|(r, _)| story.text[r.clone()].starts_with("bold")).unwrap().1;
assert_eq!(bold.over.font_family.as_deref(), Some("Source Serif 4"));
// The bundled family draws the text (an unknown family would fall back to Source Sans 3).
let cs = designcraft_compose::compose_story(&d, *sid, &Default::default());
let first_para = story.text.find('\n').unwrap();
let families: Vec<&str> = cs
.frames
.iter()
.flat_map(|f| &f.lines)
.flat_map(|l| &l.glyphs)
.filter(|g| g.visible && g.len > 0 && g.byte < first_para)
.map(|g| g.face.get().family.as_str())
.collect();
assert!(!families.is_empty() && families.iter().all(|f| *f == "Source Serif 4"), "{families:?}");
}
+34 -14
View File
@@ -449,8 +449,8 @@ pub(crate) struct Exporter<'a> {
images_by_content: ImageContentCache,
#[cfg(test)]
image_conversions: usize,
/// Placed PDFs (embedded as vector pages).
pdfs: HashMap<AssetId, Option<krilla::pdf::PdfDocument>>,
/// Placed PDFs (embedded as vector pages) and their page counts.
pdfs: HashMap<AssetId, Option<(krilla::pdf::PdfDocument, usize)>>,
/// The PDF version written; placed PDFs must not be newer.
version: PdfVersion,
/// Placed PDFs too new to embed in this version ("name is PDF 2.0"): the export fails.
@@ -1197,7 +1197,8 @@ impl Exporter<'_> {
/// VectorCraft and most tools write 1.7. PDF 1.7 changed nothing in how a page draws, so a 1.7
/// file goes in as 1.6 (its header relabelled, as `qpdf --force-version=1.6` does). A newer
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<krilla::pdf::PdfDocument> {
/// Returns the document with its page count.
fn placed_pdf(&mut self, asset: &designcraft_doc::Asset) -> Option<(krilla::pdf::PdfDocument, usize)> {
use hayro_syntax::PdfVersion as V;
let Ok(pdf) = krilla::pdf::Pdf::new(asset.data.clone()) else {
self.warn(format!("{}: can't read the placed PDF", asset.name));
@@ -1212,7 +1213,8 @@ impl Exporter<'_> {
};
let have = pdf.version();
if have <= max {
return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf)));
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
if have == V::Pdf17
&& max == V::Pdf16
@@ -1221,7 +1223,8 @@ impl Exporter<'_> {
&& pdf.version() <= max
{
self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name));
return Some(krilla::pdf::PdfDocument::new(Arc::new(pdf)));
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
let v = match have {
V::Pdf17 => "PDF 1.7",
@@ -1326,7 +1329,11 @@ impl Exporter<'_> {
if let Some(asset) = doc.assets.get(&g.asset)
&& asset.data.starts_with(b"%PDF")
{
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
// Cropped to a box: the whole page, placed so that the box spans graphic space.
let shown = asset.shown_box();
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return };
let placed = match self.pdfs.get(&g.asset) {
Some(p) => p.clone(),
None => {
@@ -1335,10 +1342,26 @@ impl Exporter<'_> {
p
}
};
if let Some(placed) = placed {
s.push_transform(&tf(g.xf));
s.draw_pdf_page(&placed, size, asset.page as usize);
s.pop();
match placed {
// krilla would fail the whole export at the end on a page the PDF doesn't have.
Some((_, pages)) if asset.page as usize >= pages => {
self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1));
}
Some((placed, _)) => {
s.push_transform(&tf(g.xf));
let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1)));
if let Some(clip) = &clip {
s.push_clip_path(clip, &FillRule::NonZero);
}
s.push_transform(&tf(Affine::translate((-l * page_w, -t * page_h))));
s.draw_pdf_page(&placed, size, asset.page as usize);
s.pop();
if clip.is_some() {
s.pop();
}
s.pop();
}
None => {}
}
return;
}
@@ -1572,10 +1595,7 @@ mod image_content_cache_tests {
}
fn asset(doc: &mut Document, bytes: Vec<u8>, name: &str) -> AssetId {
let id = AssetId(doc.alloc());
doc.assets.insert(
id,
Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), link: None, data: Arc::new(bytes), pixels: None, page: 0 }),
);
doc.assets.insert(id, Arc::new(Asset { id, name: name.into(), mime: "image/png".into(), data: Arc::new(bytes), ..Default::default() }));
id
}
fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> {
+1 -1
View File
@@ -468,7 +468,7 @@ mod tests {
let asset = AssetId(d.alloc());
let mime = designcraft_images::mime(&data).into();
let pixels = designcraft_images::pixel_size(&data);
d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, link: None, data: data.into(), pixels, page: 0 }));
d.assets.insert(asset, Arc::new(Asset { id: asset, name: name.into(), mime, data: data.into(), pixels, ..Default::default() }));
let lid = d.default_layer();
let id = ItemId(d.alloc());
let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0);
+1 -2
View File
@@ -232,10 +232,9 @@ fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
id: asset,
name: "scan.tif".into(),
mime,
link: None,
data: data.into(),
pixels: Some(px),
page: 0,
..Default::default()
}),
);
let lid = d.default_layer();
@@ -23,7 +23,7 @@ fn place(d: &mut Document, data: Vec<u8>, x: f64) {
fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str, page: u32) {
let aid = AssetId(d.alloc());
d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), link: None, data: Arc::new(data), pixels: None, page }));
d.assets.insert(aid, Arc::new(Asset { id: aid, name: name.into(), mime: mime.into(), data: Arc::new(data), page, ..Default::default() }));
let id = ItemId(d.alloc());
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0)));
item.stroke.weight = 0.0;
+107 -25
View File
@@ -762,20 +762,26 @@ impl Renderer {
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() };
let Some(pm) = images::mip(&data, asset.page, g.size.0, on_screen) else { return };
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
let [l, t, r, b] = asset.shown_box().unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t));
let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h);
let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return };
let pm = match PLATE.with(|p| p.get()) {
Some(plate) => plate_pixmap(&pm, plate),
None => pm,
};
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1);
// Only where the page is: a box reaching past it (a media box larger than the crop box)
// shows nothing there.
let rect = Rect::new(0.0, 0.0, g.size.0, g.size.1).intersect(page);
ctx.set_transform(f.view * xf * g.xf);
let sx = g.size.0 / pm.width().max(1) as f64;
let sy = g.size.1 / pm.height().max(1) as f64;
let sx = page_w / pm.width().max(1) as f64;
let sy = page_h / pm.height().max(1) as f64;
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
});
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.set_paint_transform(Affine::translate((page.x0, page.y0)) * Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
@@ -1053,10 +1059,15 @@ pub fn pdf_page_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
Some((w as f64, h as f64))
}
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off.
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String]) -> Option<Vec<u8>> {
/// PDF page `page` as PNG, its longer side about `max_side` pixels, with the `hidden` layers off;
/// only the `shown` part of it ([`designcraft_doc::Asset::shown_box`]) when given.
pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Option<Vec<u8>> {
let data = if hidden.is_empty() { bytes.to_vec() } else { pdf_hide_layers(bytes, hidden).unwrap_or_else(|| bytes.to_vec()) };
let pm = render_pdf_page(&data, page, max_side)?;
let pm = match shown {
Some(frac) => crop_pixmap(&pm, frac)?,
None => pm,
};
let pixels: Vec<u8> = pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect();
Some(Rendered { width: pm.width() as u32, height: pm.height() as u32, pixels }.to_png())
}
@@ -1076,26 +1087,98 @@ pub fn render_pdf_page(bytes: &[u8], page: usize, max_side: u32) -> Option<Pixma
Some(Pixmap::from_parts(data, pw, ph))
}
/// A page box of a PDF page (`crop`, `trim`, `bleed`, `art`, `media`) as (x, y, w, h) in points
/// from the top-left of the page as rendered (its visible crop box); missing boxes fall back to the
/// crop box, as PDF readers do.
pub fn pdf_page_box(bytes: &[u8], page: usize, kind: &str) -> Option<(f64, f64, f64, f64)> {
/// The part of PDF page `page` that `crop` shows: its left, top, right and bottom edges as
/// fractions of the page as rendered (its visible crop box, rotated as it displays), and its size
/// in points. A box the page doesn't define is its crop box, as PDF readers do. The content boxes
/// are the bounds of what the page draws, found on a render about 2048 pixels on its long side
/// (the whole page when it draws nothing).
pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) -> Option<([f64; 4], (f64, f64))> {
use designcraft_doc::PdfCrop;
use hayro::hayro_syntax::object::{Rect as PRect, dict::keys};
const WHOLE: [f64; 4] = [0.0, 0.0, 1.0, 1.0];
let pdf = hayro::hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.to_vec())).ok()?;
let p = pdf.pages().get(page)?;
let visible = p.intersected_crop_box();
let key: &[u8] = match kind {
"trim" => keys::TRIM_BOX,
"bleed" => keys::BLEED_BOX,
"art" => keys::ART_BOX,
"media" => return Some(rel(visible, p.media_box())),
_ => return Some(rel(visible, visible)),
};
let b = p.raw().get::<PRect>(key).map(|b| b.intersect(p.media_box())).unwrap_or(visible);
fn rel(v: PRect, b: PRect) -> (f64, f64, f64, f64) {
(b.x0 - v.x0, v.y1 - b.y1, b.width(), b.height())
let (w, h) = p.render_dimensions();
let (w, h) = (f64::from(w), f64::from(h));
if !(w > 0.0 && h > 0.0) {
return None;
}
Some(rel(visible, b))
let frac = match crop {
PdfCrop::Crop => WHOLE,
PdfCrop::ContentVisible => content_bounds(bytes, page).unwrap_or(WHOLE),
PdfCrop::ContentAll => {
let all_on = pdf_hide_layers(bytes, &[]);
content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE)
}
PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => {
let media = p.media_box();
let b = match crop {
PdfCrop::Media => media,
_ => {
let key: &[u8] = match crop {
PdfCrop::Trim => keys::TRIM_BOX,
PdfCrop::Bleed => keys::BLEED_BOX,
_ => keys::ART_BOX,
};
p.raw().get::<PRect>(key).map(|b| b.intersect(media)).unwrap_or_else(|| p.intersected_crop_box())
}
};
// Into rendered page space (y down, rotated, origin at the crop box).
let r = Affine::new(p.initial_transform(true).as_coeffs()).transform_rect_bbox(Rect::new(b.x0, b.y0, b.x1, b.y1));
[r.x0 / w, r.y0 / h, r.x1 / w, r.y1 / h]
}
};
let size = ((frac[2] - frac[0]) * w, (frac[3] - frac[1]) * h);
(frac.iter().all(|v| v.is_finite()) && size.0 > 0.0 && size.1 > 0.0).then_some((frac, size))
}
/// The bounds of the non-transparent pixels of PDF page `page`, as fractions of the page.
fn content_bounds(bytes: &[u8], page: usize) -> Option<[f64; 4]> {
let pm = render_pdf_page(bytes, page, 2048)?;
let (pw, ph) = (usize::from(pm.width()), usize::from(pm.height()));
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
for (i, px) in pm.data().iter().enumerate() {
if px.a > 0 {
let (x, y) = (i % pw.max(1), i / pw.max(1));
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x + 1);
y1 = y1.max(y + 1);
}
}
(x0 < x1 && y0 < y1 && pw > 0 && ph > 0).then(|| [x0 as f64 / pw as f64, y0 as f64 / ph as f64, x1 as f64 / pw as f64, y1 as f64 / ph as f64])
}
/// The part of `pm` inside `frac` (left, top, right, bottom as fractions of it); what lies outside
/// `pm` is transparent.
pub fn crop_pixmap(pm: &Pixmap, frac: [f64; 4]) -> Option<Pixmap> {
let (pw, ph) = (f64::from(pm.width()), f64::from(pm.height()));
let [l, t, r, b] = [frac[0] * pw, frac[1] * ph, frac[2] * pw, frac[3] * ph].map(f64::round);
let (w, h) = (r - l, b - t);
if !(w >= 1.0 && h >= 1.0 && w <= f64::from(u16::MAX) && h <= f64::from(u16::MAX)) {
return None;
}
let (w, h) = (w as u16, h as u16);
let mut out = Pixmap::new(w, h);
let src = pm.data();
let dst = out.data_mut();
for y in 0..usize::from(h) {
let sy = y as f64 + t;
if sy < 0.0 || sy >= ph {
continue;
}
for x in 0..usize::from(w) {
let sx = x as f64 + l;
if sx < 0.0 || sx >= pw {
continue;
}
let (Some(p), Some(q)) = (src.get(sy as usize * usize::from(pm.width()) + sx as usize), dst.get_mut(y * usize::from(w) + x)) else {
continue;
};
*q = *p;
}
}
Some(out)
}
/// Separations Preview on a rendered image: `plate` 0–3 shows that process plate (C, M, Y, K) as
@@ -1496,13 +1579,12 @@ mod tests {
d.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
page: 0,
id: aid,
name: "red.png".into(),
mime: "image/png".into(),
link: None,
data: Arc::new(png),
pixels: Some((4, 4)),
..Default::default()
}),
);
let id = designcraft_doc::ItemId(d.alloc());
+3 -1
View File
@@ -1378,6 +1378,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Crop to:"));
let cur = d.s("crop");
let label = |v: &str| match v {
"contentVisible" => crate::i18n::tr(&app.ui.language, "Bounding Box (Visible Layers Only)"),
"contentAll" => crate::i18n::tr(&app.ui.language, "Bounding Box (All Layers)"),
"trim" => crate::i18n::tr(&app.ui.language, "Trim"),
"bleed" => crate::i18n::tr(&app.ui.language, "Bleed"),
"art" => crate::i18n::tr(&app.ui.language, "Art"),
@@ -1385,7 +1387,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
_ => crate::i18n::tr(&app.ui.language, "Crop"),
};
egui::ComboBox::from_id_salt("pdf_crop").selected_text(crate::rtl::widget(ui, label(&cur))).show_ui(ui, |ui| {
for v in ["crop", "trim", "bleed", "art", "media"] {
for v in designcraft_doc::PdfCrop::ALL.map(designcraft_doc::PdfCrop::name) {
if ui.selectable_label(label(&cur) == label(v), label(v)).clicked() {
d.fields.insert("crop".into(), json!(v));
}
+20
View File
@@ -613,6 +613,26 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Trim", ["Trim", "Trim", "Trim", "Trim", "裁切"]),
(
"Bounding Box (Visible Layers Only)",
[
"Begrenzungsrahmen (nur sichtbare Ebenen)",
"Cadre de sélection (calques visibles uniquement)",
"Cuadro delimitador (solo capas visibles)",
"バウンディングボックス(表示レイヤーのみ)",
"边界框(仅可见图层)",
],
),
(
"Bounding Box (All Layers)",
[
"Begrenzungsrahmen (alle Ebenen)",
"Cadre de sélection (tous les calques)",
"Cuadro delimitador (todas las capas)",
"バウンディングボックス(すべてのレイヤー)",
"边界框(所有图层)",
],
),
("Type:", ["Type:", "Type:", "Type:", "Type:", "类型:"]),
("Typical", ["Typical", "Typical", "Typical", "Typical", "典型"]),
("UI Size:", ["UI Size:", "UI Size:", "UI Size:", "UI Size:", "UI 大小:"]),