Files
designcraft/crates/pdf/src/export.rs
T
aiongg 18ae8e4761 Merge upstream main a184db6f8 with our 15 PRs (batch-0) into next
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's

i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
2026-10-16 03:09:22 +00:00

1959 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`.
use std::collections::HashMap;
use std::sync::Arc;
use craft_pdf::write::backend as krilla;
use craft_pdf::write::{self, Metadata, Writer};
use designcraft_color::swatch::{ColorType, SwatchValue};
use designcraft_color::{BlendMode as DcBlend, Color, GradientKind};
use designcraft_compose::Cache;
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, SpreadRef, StrokeAlign, StrokeType};
use designcraft_geom::kurbo::{PathEl, Shape as _};
use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::image::Image;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Tag, TagGroup, TagTree};
use crate::{ExportReport, PdfError, PdfOptions, Result, Standard};
/// Export `doc` as PDF bytes.
pub fn export_pdf(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<Vec<u8>> {
export_pdf_with_report(doc, cache, opts).map(|r| r.bytes)
}
/// One output page: a document page or a spread, in spread coordinates.
pub(crate) struct Sheet {
pub spread: usize,
pub trim: Rect,
pub bleed: Rect,
pub media: Rect,
/// Page name(s) for the page-information mark.
pub label: String,
}
pub(crate) const MARK_LEN: f64 = 18.0;
/// Whether a layer's items go into the PDF.
pub(crate) fn layer_exported(layer: &designcraft_doc::Layer) -> bool {
layer.visible && layer.printable
}
/// Whether an item and everything inside it go into the PDF, its layer aside.
pub(crate) fn item_exported(it: &Item) -> bool {
!it.hidden && !it.nonprinting
}
/// Every item of spread `si` that goes into the PDF, with its parent's transform: the items on
/// exported layers, leaving out hidden and non-printing items with everything inside them.
pub(crate) fn exported_items(doc: &Document, si: usize) -> Vec<(Affine, &Item)> {
let Some(sp) = doc.spreads.get(si) else { return Vec::new() };
let mut stack: Vec<(Affine, &Item)> =
sp.items.iter().filter(|it| doc.layer(it.layer).is_some_and(layer_exported)).map(|it| (Affine::IDENTITY, &**it)).collect();
let mut out = Vec::new();
while let Some((xf, it)) = stack.pop() {
if !item_exported(it) {
continue;
}
stack.extend(it.shown_children().map(|c| (xf * it.xf, &**c)));
out.push((xf, it));
}
out
}
fn bleed_lr(side: PageSide, b: [f64; 4]) -> (f64, f64) {
// Bleed is top, bottom, inside, outside; single-sided pages read inside as left.
if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }
}
/// The spread each exported PDF page shows (in page order).
pub fn sheet_spreads(doc: &Document, opts: &PdfOptions) -> Vec<usize> {
sheets(doc, opts).map(|v| v.iter().map(|s| s.spread).collect()).unwrap_or_default()
}
fn sheets(doc: &Document, opts: &PdfOptions) -> Result<Vec<Sheet>> {
let count = doc.page_count();
let pages: Vec<usize> = match &opts.pages {
Some(v) => v.clone(),
None => (0..count).collect(),
};
if pages.is_empty() {
return Err(PdfError::NoPages);
}
if let Some(bad) = pages.iter().find(|p| **p >= count) {
return Err(PdfError::BadPage(bad.saturating_add(1)));
}
let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 });
let mut out: Vec<Sheet> = Vec::new();
let mut seen_spreads: Vec<usize> = Vec::new();
for abs in pages {
let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs.saturating_add(1)))?;
let sp: &Spread = &doc.spreads[si];
let (trim, l, r, label) = if opts.spreads {
if seen_spreads.contains(&si) {
continue;
}
seen_spreads.push(si);
let first = doc.first_page_of_spread(si);
// The outer edges by position: a right-to-left spread lists its right page first.
let outer = sp.outer_page_indices();
let (l, _) = outer.and_then(|(l, _)| sp.pages.get(l)).map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
let (_, r) = outer.and_then(|(_, r)| sp.pages.get(r)).map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
let names: Vec<String> = (0..sp.pages.len()).map(|i| doc.page_name(first + i)).collect();
(sp.bounds(), l, r, names.join("–"))
} else {
let p = &sp.pages[pi];
let (l, r) = bleed_lr(p.side, b);
(p.bounds(), l, r, doc.page_name(abs))
};
let bleed = Rect::new(trim.x0 - l, trim.y0 - b[0], trim.x1 + r, trim.y1 + b[1]);
let media = if opts.marks.any() {
let m = mark_start(opts, b) + MARK_LEN + 6.0;
trim.inflate(m, m)
} else {
bleed
};
out.push(Sheet { spread: si, trim, bleed, media, label });
}
Ok(out)
}
/// Distance from the trim edge where marks start: the offset, but never inside the bleed.
pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
bleed.iter().copied().fold(opts.marks.offset.max(0.0), f64::max)
}
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
write::guard(|| export_pdf_with_report_inner(doc, cache, opts))
}
fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
let sheets = sheets(doc, opts)?;
let warnings = Vec::new();
let (version, archival) = match opts.standard {
Standard::None => (PdfVersion::Pdf17, None),
Standard::PdfX4 => (PdfVersion::Pdf16, None),
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
};
let configuration = write::configuration(version, archival)?;
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let created = opts.created.or_else(now_unix);
let metadata = Metadata {
creator: "DesignCraft".into(),
producer: "DesignCraft".into(),
title: Some(title.clone()),
authors: opts.author.iter().cloned().collect(),
created: created.map(date_time),
..Default::default()
};
let mut writer = Writer::new(settings, metadata);
let pdf = writer.document_mut();
let mut ex = Exporter {
drawing_items: Vec::new(),
doc,
cache,
opts,
clip: Rect::ZERO,
warnings,
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
version,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: archival.is_some(),
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: None,
para_tags: HashMap::new(),
};
if ex.rgb_only {
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
}
if let Some(o) = crate::links::outline(doc, &sheets) {
pdf.set_outline(o);
}
for (sheet_idx, sh) in sheets.iter().enumerate() {
let size = Size::from_wh(sh.media.width().max(1.0) as f32, sh.media.height().max(1.0) as f32).ok_or(PdfError::NoPages)?;
let local = |r: Rect| {
krilla::geom::Rect::from_ltrb(
(r.x0 - sh.media.x0) as f32,
(r.y0 - sh.media.y0) as f32,
(r.x1 - sh.media.x0) as f32,
(r.y1 - sh.media.y0) as f32,
)
};
let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed));
let mut page = pdf.start_page_with(settings);
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2())));
ex.clip = sh.bleed;
if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) {
s.push_clip_path(&clip, &FillRule::NonZero);
ex.spread(&mut s, sh.spread);
s.pop();
}
if opts.tagged {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Page, None)));
}
ex.marks(&mut s, sh, &title, created);
if opts.tagged {
s.end_tagged();
}
s.pop();
s.finish();
for a in crate::links::annotations(doc, cache, &sheets, sheet_idx) {
page.add_annotation(a);
}
page.finish();
}
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
// Transparency blends in the document's blend space: CMYK for print (and always in PDF/X).
let mut warnings = ex.warnings;
if !ex.rgb_only && (doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk || opts.standard == Standard::PdfX4) {
// The rewrite matches krilla's exact output; in PDF/X-4, `check_pdfx4` reports any
// 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()),
}
}
}
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
match crate::pdfx::make_pdfx4(&bytes, &title) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
}
for issue in crate::pdfx::check_pdfx4(&bytes) {
warnings.push(format!("PDF/X-4: {issue}"));
}
}
warnings.dedup();
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
}
/// One PDF from several (a book's documents), every page carried over as vectors; each part
/// comes with its pages' sizes (points).
pub fn merge_pdfs(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
write::guard(|| merge_pdfs_inner(parts, title))
}
fn merge_pdfs_inner(parts: &[(Vec<u8>, Vec<(f32, f32)>)], title: Option<&str>) -> Result<Vec<u8>> {
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: title.map(str::to_owned), ..Default::default() };
let mut writer = Writer::new(krilla::SerializeSettings::default(), metadata);
let pdf = writer.document_mut();
let mut pages = 0;
for (bytes, sizes) in parts {
let src = krilla::pdf::Pdf::new(Arc::new(bytes.clone())).map_err(|e| PdfError::Write(format!("{e:?}")))?;
let doc = krilla::pdf::PdfDocument::new(Arc::new(src));
for (i, &(w, h)) in sizes.iter().enumerate() {
let size = Size::from_wh(w.max(1.0), h.max(1.0)).ok_or(PdfError::NoPages)?;
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
s.draw_pdf_page(&doc, size, i);
s.finish();
page.finish();
pages += 1;
}
}
if pages == 0 {
return Err(PdfError::NoPages);
}
writer.finish().map_err(PdfError::from)
}
/// File › Print Booklet: how pages pair up on printer spreads.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BookletKind {
/// Folded and stapled: pages padded to a multiple of 4, outer pages together.
#[default]
SaddleStitch,
/// Two pages side by side in reading order.
TwoUpConsecutive,
}
#[derive(Clone, Debug, Default)]
pub struct BookletOptions {
pub kind: BookletKind,
/// Gap between the two pages of a printer spread (points).
pub space_between: f64,
pub title: Option<String>,
}
/// The printer spreads for `n` pages: (left, right) absolute page indices, `None` = blank.
pub fn booklet_pairs(n: usize, kind: BookletKind) -> Vec<(Option<usize>, Option<usize>)> {
let page = |i: usize| (i < n).then_some(i);
match kind {
BookletKind::TwoUpConsecutive => (0..n.div_ceil(2)).map(|k| (page(2 * k), page(2 * k + 1))).collect(),
BookletKind::SaddleStitch => {
let m = n.div_ceil(4).max(1) * 4;
(0..m / 2).map(|i| if i % 2 == 0 { (page(m - 1 - i), page(i)) } else { (page(i), page(m - 1 - i)) }).collect()
}
}
}
/// An imposed PDF: two document pages per sheet, in booklet order.
pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
write::guard(|| export_booklet_inner(doc, cache, opts))
}
fn export_booklet_inner(doc: &Document, cache: &Cache, opts: &BookletOptions) -> Result<ExportReport> {
let n = doc.page_count();
if n == 0 {
return Err(PdfError::NoPages);
}
let pdf_opts = PdfOptions { title: opts.title.clone(), ..PdfOptions::default() };
let settings = krilla::SerializeSettings { compress_content_streams: true, ..Default::default() };
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let metadata = Metadata { creator: "DesignCraft".into(), producer: "DesignCraft".into(), title: Some(title), ..Default::default() };
let mut writer = Writer::new(settings, metadata);
let pdf = writer.document_mut();
let mut ex = Exporter {
drawing_items: Vec::new(),
doc,
cache,
opts: &pdf_opts,
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
#[cfg(test)]
image_conversions: 0,
pdfs: HashMap::new(),
// krilla's default.
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: None,
para_tags: HashMap::new(),
};
let pairs = booklet_pairs(n, opts.kind);
// Sheet size from the largest page.
let (mut pw, mut ph) = (0.0f64, 0.0f64);
for i in 0..n {
if let Some((si, pi)) = doc.page_loc(i) {
let p = &doc.spreads[si].pages[pi];
pw = pw.max(p.width);
ph = ph.max(p.height);
}
}
let gap = opts.space_between.max(0.0);
let size = Size::from_wh((2.0 * pw + gap) as f32, ph as f32).ok_or(PdfError::NoPages)?;
for (left, right) in &pairs {
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
#[cfg(all(test, not(target_arch = "wasm32")))]
unwind_tests::inject(&mut s);
for (slot, abs) in [(0.0, left), (pw + gap, right)] {
let Some((si, pi)) = abs.and_then(|a| doc.page_loc(a)) else { continue };
let p = &doc.spreads[si].pages[pi];
let r = Rect::new(p.x, 0.0, p.x + p.width, p.height);
// Pages narrower than the slot sit against the fold.
let dx = if slot == 0.0 { pw - p.width } else { 0.0 };
s.push_transform(&tf(Affine::translate((slot + dx - p.x, (ph - p.height) / 2.0))));
ex.clip = r;
if let Some(clip) = to_path(&r.to_path(0.1)) {
s.push_clip_path(&clip, &FillRule::NonZero);
ex.spread(&mut s, si);
s.pop();
}
s.pop();
}
s.finish();
page.finish();
}
ex.check_placed_versions()?;
let mut bytes = writer.finish().map_err(PdfError::from)?;
if doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk {
crate::pdfx::cmyk_group_spaces(&mut bytes);
}
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, pages: pairs.len(), warnings })
}
#[cfg(not(target_arch = "wasm32"))]
fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
}
#[cfg(target_arch = "wasm32")]
fn now_unix() -> Option<i64> {
None
}
fn date_time(t: i64) -> krilla::metadata::DateTime {
let c = civil(t);
krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16)
.month(c.1)
.day(c.2)
.hour(c.3)
.minute(c.4)
.second(c.5)
.utc_offset_hour(0)
.utc_offset_minute(0)
}
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
}
/// A successful conversion and its name-independent diagnostic. Replayed once for each asset.
#[derive(Clone)]
struct ConvertedImage {
image: Image,
warning: Option<&'static str>,
}
const CMYK_FALLBACK_WARNING: &str = "converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)";
#[cfg(not(test))]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage>;
// Unit tests force collisions through the real cache path; production keeps RandomState.
#[cfg(test)]
type ImageContentCache = HashMap<Arc<Vec<u8>>, ConvertedImage, std::hash::BuildHasherDefault<ConstantImageHasher>>;
#[cfg(test)]
#[derive(Default)]
struct ConstantImageHasher;
#[cfg(test)]
impl std::hash::Hasher for ConstantImageHasher {
fn finish(&self) -> u64 {
0
}
fn write(&mut self, _: &[u8]) {}
}
pub(crate) struct Exporter<'a> {
pub doc: &'a Document,
pub cache: &'a Cache,
pub opts: &'a PdfOptions,
/// Current sheet's visible area (spread space) for culling.
pub clip: Rect,
pub warnings: Vec<String>,
drawing_items: Vec<designcraft_doc::ItemId>,
images: HashMap<AssetId, Option<Image>>,
// Immutable export options (including rgb_only) make bytes the complete conversion key.
// PDF-page/SVG/media routing precedes this path. Failures remain per AssetId.
images_by_content: ImageContentCache,
#[cfg(test)]
image_conversions: usize,
/// 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.
too_new: Vec<String>,
/// Parsed placed SVGs.
svgs: HashMap<AssetId, Option<Arc<designcraft_images::usvg::Tree>>>,
pub fonts: HashMap<u32, Option<krilla::text::Font>>,
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
pub rgb_only: bool,
/// Tagged PDF: the structure in reading order (stories gather their frames' content).
tags: Vec<TagEntry>,
story_tags: HashMap<designcraft_doc::StoryId, Vec<Identifier>>,
/// While drawing a story's frame for the structure tree: the story (text runs are tagged
/// per paragraph, everything else in the frame is an artifact).
pub(crate) tag_story: Option<designcraft_doc::StoryId>,
/// While drawing a frame's text: the frame's box in text space, which a gradient on the text
/// spans (as InDesign lays it across the frame).
pub(crate) text_area: Option<Rect>,
/// Marked content of each (story, paragraph index).
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
}
enum TagEntry {
Story(designcraft_doc::StoryId),
Figure(TagGroup),
}
pub(crate) fn tf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
pub(crate) fn to_path(bp: &BezPath) -> Option<Path> {
let mut pb = PathBuilder::new();
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => pb.close(),
}
}
pb.finish()
}
pub(crate) fn norm(v: f32) -> NormalizedF32 {
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
}
fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
use krilla::color::RegularColor as R;
if rgb_only && matches!(c, Color::Cmyk { .. }) {
let [r, g, b] = c.to_rgb();
return R::Rgb(rgb::Color::new(q(r), q(g), q(b)));
}
match *c {
Color::Rgb { r, g, b } => R::Rgb(rgb::Color::new(q(r), q(g), q(b))),
Color::Cmyk { c, m, y, k } => R::Cmyk(cmyk::Color::new(q(c), q(m), q(y), q(k))),
// DesignCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness.
Color::Gray { k } => R::Luma(luma::Color::new(q(1.0 - k))),
}
}
/// A process colour in its own device space (CMYK → DeviceCMYK, RGB → DeviceRGB, Gray → DeviceGray).
/// `rgb_only` converts CMYK to RGB (PDF/A without a CMYK output profile).
pub(crate) fn device(c: &Color, rgb_only: bool) -> krilla::color::Color {
match regular(c, rgb_only) {
krilla::color::RegularColor::Rgb(v) => v.into(),
krilla::color::RegularColor::Cmyk(v) => v.into(),
krilla::color::RegularColor::Luma(v) => v.into(),
}
}
/// `[Registration]` prints on every plate: `/Separation /All`.
pub(crate) fn registration(tint: f32, rgb_only: bool) -> krilla::color::Color {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let alt = regular(&Color::cmyk(1.0, 1.0, 1.0, 1.0), rgb_only);
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into()
}
fn blend(b: DcBlend) -> krilla::blend::BlendMode {
use krilla::blend::BlendMode as K;
match b {
DcBlend::Normal => K::Normal,
DcBlend::Darken => K::Darken,
DcBlend::Multiply => K::Multiply,
DcBlend::ColorBurn => K::ColorBurn,
DcBlend::Lighten => K::Lighten,
DcBlend::Screen => K::Screen,
DcBlend::ColorDodge => K::ColorDodge,
DcBlend::Overlay => K::Overlay,
DcBlend::SoftLight => K::SoftLight,
DcBlend::HardLight => K::HardLight,
DcBlend::Difference => K::Difference,
DcBlend::Exclusion => K::Exclusion,
DcBlend::Hue => K::Hue,
DcBlend::Saturation => K::Saturation,
DcBlend::Color => K::Color,
DcBlend::Luminosity => K::Luminosity,
}
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
pub(crate) fn solid_fill(c: krilla::color::Color, opacity: f32) -> Fill {
Fill { paint: c.into(), opacity: norm(opacity), rule: FillRule::NonZero }
}
impl Exporter<'_> {
/// A print document, or a PDF/X file (whose output intent is CMYK).
pub(crate) fn for_print(&self) -> bool {
self.doc.settings.intent == designcraft_doc::Intent::Print || self.opts.standard == Standard::PdfX4
}
pub(crate) fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
/// A swatch + tint as a PDF colour: spot swatches (and tints of them) become `/Separation`
/// with the swatch's values as the alternate space; process colours stay in their device space.
pub(crate) fn swatch_color(&self, name: &str, tint: f32) -> Option<krilla::color::Color> {
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
let mut n = name;
let mut t = tint;
for _ in 0..8 {
let sw = self.doc.swatch(n)?;
match &sw.value {
SwatchValue::None => return None,
// [Paper] prints no ink (it knocks out what lies below); its colour is only what
// the screen shows. Written as RGB white (`1 1 1 rg`) it would put DeviceRGB in a
// CMYK print file.
SwatchValue::Paper { .. } if self.for_print() => return Some(device(&Color::cmyk(0.0, 0.0, 0.0, 0.0), self.rgb_only)),
SwatchValue::Tint { base, tint: bt } => {
t *= bt;
n = base;
}
SwatchValue::Registration => return Some(registration(t, self.rgb_only)),
SwatchValue::Color { color: _, color_type: ColorType::Spot } => {
// Ink Manager: an alias prints on another ink; converted inks go to process.
let (ink, process) = self.doc.inks.resolve(&sw.name);
let target =
self.doc.swatch(ink).filter(|w| matches!(w.value, SwatchValue::Color { color_type: ColorType::Spot, .. })).unwrap_or(sw);
let SwatchValue::Color { color, .. } = &target.value else { break };
if process {
return Some(device(&designcraft_color::swatch::apply_tint(*color, t), self.rgb_only));
}
let space = SeparationSpace::new(SeparationColorant::Custom(target.name.clone()), regular(color, self.rgb_only));
return Some(SepColor::new(q(t), space).into());
}
_ => break,
}
}
self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only))
}
fn spread(&mut self, s: &mut Surface, si: usize) {
let doc = self.doc;
let Some(sp) = doc.spreads.get(si) else { return };
let first = doc.first_page_of_spread(si);
for layer in doc.layers.iter().rev() {
if !layer_exported(layer) {
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);
}
}
}
for it in &sp.items {
if it.layer == layer.id {
self.top_item(s, it, Affine::IDENTITY, None, false);
}
}
}
}
/// Push blend/opacity for an item drawn as a group. Returns the number of pushes.
fn push_group(s: &mut Surface, opacity: f32, mode: DcBlend) -> usize {
let mut n = 0;
if mode != DcBlend::Normal {
s.push_blend_mode(blend(mode));
n += 1;
}
if opacity < 0.999 {
s.push_opacity(norm(opacity));
n += 1;
} else if mode != DcBlend::Normal {
s.push_isolated();
n += 1;
}
n
}
/// Isolate Blending / Knockout groups: an isolated transparency group (knockout is drawn
/// isolated too).
fn push_isolation(s: &mut Surface, it: &Item) -> usize {
if it.isolate || it.knockout {
s.push_isolated();
1
} else {
0
}
}
/// A spread or parent item, inside its structure tag when tagging.
fn top_item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>, on_parent: bool) {
if !self.opts.tagged || !item_exported(it) {
return self.item(s, it, parent, page);
}
let story = it.text_frame().map(|t| t.story);
let figure = !it.alt_text.is_empty() || matches!(it.content, Content::Graphic(_)) || it.has_nested_items();
if on_parent || it.export_options.artifact || (story.is_none() && !figure) {
// Page furniture and decoration.
let kind = if on_parent { ArtifactType::Page } else { ArtifactType::Other };
s.start_tagged(ContentTag::Artifact(Artifact::new(kind, None)));
self.item(s, it, parent, page);
s.end_tagged();
return;
}
if let Some(sid) = story {
// Text runs tag themselves by paragraph; the rest of the frame is an artifact.
if let std::collections::hash_map::Entry::Vacant(e) = self.story_tags.entry(sid) {
e.insert(Vec::new());
self.tags.push(TagEntry::Story(sid));
}
self.tag_story = Some(sid);
self.item(s, it, parent, page);
self.tag_story = None;
return;
}
let id = s.start_tagged(ContentTag::Other);
self.item(s, it, parent, page);
s.end_tagged();
let alt = (!it.alt_text.is_empty()).then(|| it.alt_text.clone());
let mut g = TagGroup::new(Tag::Figure(alt));
g.push(id);
self.tags.push(TagEntry::Figure(g));
}
/// The structure tree: stories (one paragraph each, its frames in thread order of appearance)
/// and figures, in the order they first appear.
fn tag_tree(&mut self) -> TagTree {
let mut tree = TagTree::new();
for e in std::mem::take(&mut self.tags) {
match e {
TagEntry::Story(sid) => {
// One structure element per paragraph: headings and quotes from the
// paragraph style's Export Tagging, else P.
let mut paras: Vec<usize> = self.para_tags.keys().filter(|(s, _)| *s == sid).map(|(_, p)| *p).collect();
paras.sort_unstable();
let story = self.doc.story(sid);
for pi in paras {
let ids = self.para_tags.remove(&(sid, pi)).unwrap_or_default();
let style = story.and_then(|st| st.paras.get(pi)).map(|p| p.style.clone()).unwrap_or_default();
let tag = self.doc.styles.export_tag(&style, false).map(|e| e.tag.clone()).unwrap_or_default();
let text = || story.and_then(|st| st.para_ranges().get(pi).map(|r| st.text[r.clone()].trim().to_string()));
let kind: krilla::tagging::TagKind = match tag.as_str() {
h if h.len() == 2 && h.starts_with('h') => {
let n = h[1..].parse::<u16>().unwrap_or(1).clamp(1, 6);
Tag::Hn(std::num::NonZeroU16::new(n).unwrap_or(std::num::NonZeroU16::MIN), text()).into()
}
"blockquote" => Tag::BlockQuote.into(),
_ => Tag::P.into(),
};
let mut g = TagGroup::new(kind);
for id in ids {
g.push(id);
}
tree.push(g);
}
}
TagEntry::Figure(g) => tree.push(g),
}
}
tree
}
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
if self.drawing_items.len() >= 64 || self.drawing_items.contains(&it.id) {
self.warnings.push(format!("Cyclic or excessively nested item {} was not rendered", it.id));
return;
}
self.drawing_items.push(it.id);
self.item_guarded(s, it, parent, page);
self.drawing_items.pop();
}
fn item_guarded(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
let gf = &it.effects.gradient_feather;
if !gf.on || !item_exported(it) {
return self.item_inner(s, it, parent, page);
}
// Gradient feather: a luminosity mask (white = opaque) over the object.
let xf = parent * it.xf;
let (p0, p1) = gf.points(it.feather_bounds());
let gray = |a: f32| -> krilla::color::Color { luma::Color::new((a.clamp(0.0, 1.0) * 255.0).round() as u8).into() };
let stops = vec![
Stop { offset: NormalizedF32::ZERO, color: gray(gf.start), opacity: NormalizedF32::ONE },
Stop { offset: NormalizedF32::ONE, color: gray(gf.end), opacity: NormalizedF32::ONE },
];
let paint: krilla::paint::Paint = if gf.radial {
let r = (p1 - p0).hypot().max(1e-3) as f32;
RadialGradient {
fx: p0.x as f32,
fy: p0.y as f32,
fr: 0.0,
cx: p0.x as f32,
cy: p0.y as f32,
cr: r,
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
} else {
LinearGradient {
x1: p0.x as f32,
y1: p0.y as f32,
x2: p1.x as f32,
y2: p1.y as f32,
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
};
// The gradient keeps its item-space geometry, but its mask covers the complete exported
// area (including bleed), so distant child shadows and thick strokes are never cut off.
let mut sb = s.stream_builder();
let mut ms = sb.surface();
if let Some(p) = to_path(&self.clip.to_path(0.1)) {
ms.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
ms.draw_path(&p);
}
ms.finish();
let stream = sb.finish();
s.push_mask(krilla::mask::Mask::new(stream, krilla::mask::MaskType::Luminosity));
self.item_inner(s, it, parent, page);
s.pop();
}
fn item_inner(&mut self, s: &mut Surface, it: &Item, parent: Affine, page: Option<usize>) {
if !item_exported(it) {
return;
}
let ds = &it.effects.drop_shadow;
let grow = it.stroke.extent() + 2.0 + if ds.on { ds.distance.abs() + ds.size.abs() } else { 0.0 };
let vb = parent.transform_rect_bbox(it.bounds()).inflate(grow, grow);
if !rects_overlap(vb, self.clip) {
return;
}
let xf = parent * it.xf;
if it.has_nested_items() {
// A frame with items pasted into it: fill, the items clipped to the frame, stroke.
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let path = to_path(&bp);
let pushes = Self::push_group(s, it.opacity, it.blend);
s.push_transform(&tf(xf));
if !it.fill.is_none()
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
}
s.pop();
// The clip in page space, so the children keep their absolute transforms.
let mut page_bp = bp.clone();
page_bp.apply_affine(xf);
let clip = to_path(&page_bp);
if let Some(c) = &clip {
s.push_clip_path(c, &FillRule::NonZero);
}
for c in it.children() {
self.item(s, c, xf, page);
}
if clip.is_some() {
s.pop();
}
if !it.stroke.is_none()
&& let Some(p) = &path
{
s.push_transform(&tf(xf));
self.stroke(s, it, &bp, p);
s.pop();
}
for _ in 0..pushes {
s.pop();
}
return;
}
if !it.children().is_empty() {
let pushes = Self::push_group(s, it.opacity, it.blend) + Self::push_isolation(s, it);
for c in it.shown_children() {
self.item(s, c, xf, page);
}
for _ in 0..pushes {
s.pop();
}
return;
}
let doc = self.doc;
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
let path = to_path(&bp);
let pushes = Self::push_group(s, it.opacity, it.blend);
let frame_art = self.tag_story.is_some();
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
// Drop shadow (simple offset silhouette, like the renderer).
if ds.on
&& let Some(p) = &path
&& let Some(c) = self.swatch_color(&ds.color, 1.0)
{
let a = self.doc.light_angle(ds.angle, ds.global_light).to_radians();
let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance);
s.push_transform(&tf(Affine::translate(off) * xf));
s.set_stroke(None);
s.set_fill(Some(solid_fill(c, ds.opacity)));
s.draw_path(p);
s.set_fill(None);
s.pop();
}
s.push_transform(&tf(xf));
// Fill.
if !it.fill.is_none()
&& let Some(p) = &path
&& let Some(paint) = self.fill_paint(&it.fill, bp.bounding_box())
{
if it.fill.overprint {
self.warn("overprint is not exported yet");
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(p);
s.set_fill(None);
}
if frame_art {
s.end_tagged();
}
// Content.
match &it.content {
Content::Graphic(g) => {
let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
if let Some(p) = &path {
s.push_clip_path(p, &FillRule::NonZero);
self.graphic(s, g);
s.pop();
}
}
Content::Text(tfr) => {
let cs = self.cache.get(doc, tfr.story, page);
if let (Some(ft), Some(pt)) = (cs.frame(it.id), &tfr.options.path) {
// Type on a path: the glyphs as outlines along the path.
for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) {
let st = &cs.styles[si as usize];
// A gradient spans the path's box.
let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() };
if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) {
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&p);
s.set_fill(None);
}
}
} else if let Some(ft) = cs.frame(it.id) {
let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or("");
let local = doc.text_local(it);
if local != Affine::IDENTITY {
s.push_transform(&tf(local));
}
self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds()));
self.frame_text(s, &cs, ft, text);
self.text_area = None;
if local != Affine::IDENTITY {
s.pop();
}
}
}
_ => {}
}
// Stroke.
if !it.stroke.is_none()
&& let Some(p) = &path
{
if frame_art {
s.start_tagged(ContentTag::Artifact(Artifact::new(ArtifactType::Other, None)));
}
self.stroke(s, it, &bp, p);
if frame_art {
s.end_tagged();
}
}
s.pop();
for _ in 0..pushes {
s.pop();
}
if let Content::Text(tfr) = &it.content {
let cs = self.cache.get(doc, tfr.story, page);
if let Some(ft) = cs.frame(it.id) {
self.frame_objects(s, ft, xf * doc.text_local(it), page);
}
}
}
/// Draw embedded page items after restoring the surface, retaining spread-space
/// transforms for page culling (also for anonymous note and cell stories).
fn frame_objects(&mut self, s: &mut Surface, ft: &designcraft_compose::FrameText, xf: Affine, page: Option<usize>) {
let mut pending = vec![(ft, xf)];
while let Some((frame, transform)) = pending.pop() {
for object in &frame.objects {
self.item(s, &object.object.item, transform * Affine::translate(object.origin.to_vec2()), page);
}
for note in &frame.notes {
if let Some(frame) = note.text.frames.first() {
pending.push((frame, transform * Affine::translate(note.origin.to_vec2())));
}
}
for cell in frame.tables.iter().flat_map(|t| &t.cells) {
if let Some(frame) = cell.text.frames.first() {
pending.push((frame, transform * Affine::translate(cell.origin.to_vec2())));
}
}
}
}
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
self.fill_paint_in(fill, bounds, Affine::IDENTITY)
}
/// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one
/// (text runs: the frame's text space under the run's origin).
pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option<krilla::paint::Paint> {
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
let kind = g.kind;
let mut expanded = g.expanded_stops();
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
for (_, c, _) in &mut expanded {
if let Color::Gray { k } = *c {
*c = Color::cmyk(0.0, 0.0, 0.0, k);
}
}
}
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
let components = |c: &Color| match c {
Color::Cmyk { .. } if !self.rgb_only => 4,
Color::Gray { .. } => 1,
_ => 3,
};
let mixed = expanded.first().is_some_and(|(_, first, _)| expanded.iter().any(|(_, c, _)| components(c) != components(first)));
if mixed {
self.warn(format!("gradient '{swatch}': mixed color spaces converted to RGB (colour appearance and separations may change)"));
}
let mut stops: Vec<Stop> = Vec::new();
let mut last = 0.0f32;
for (o, c, a) in expanded {
let o = o.clamp(last, 1.0);
last = o;
let color = if mixed && !matches!(c, Color::Rgb { .. }) {
let [r, g, b] = c.to_rgb();
rgb::Color::new(q(r), q(g), q(b)).into()
} else {
// Preserve RGB values, including already-interpolated display RGB, and
// leave every homogeneous gradient on its existing device-space path.
device(&c, self.rgb_only)
};
stops.push(Stop { offset: norm(o), color, opacity: norm(a) });
}
if stops.is_empty() {
return None;
}
return Some(match kind {
GradientKind::Radial => {
// A circle stretched into the object's ellipse, as on screen.
let (c, rx, ry) = fill.radial_ellipse(bounds);
let rx = rx.max(1e-3);
let stretch = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(1.0, ry / rx) * Affine::translate(-c.to_vec2());
let (cx, cy) = (c.x as f32, c.y as f32);
RadialGradient {
fx: cx,
fy: cy,
fr: 0.0,
cx,
cy,
cr: rx as f32,
transform: tf(xf * stretch),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
}
_ => {
let (p0, p1) = fill.linear_axis(bounds);
LinearGradient {
x1: p0.x as f32,
y1: p0.y as f32,
x2: p1.x as f32,
y2: p1.y as f32,
transform: tf(xf),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into()
}
});
}
self.swatch_color(swatch, tint).map(Into::into)
}
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
let st = &it.stroke;
// A gradient swatch runs across the path's box, as on a fill.
let paint: krilla::paint::Paint = if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, &st.swatch).is_some() {
let Some(p) = self.fill_paint(&st.as_fill(), bp.bounding_box()) else { return };
p
} else {
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
c.into()
};
let closed = it.path.is_closed();
let mut cap = match st.cap {
designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,
designcraft_doc::Cap::Round => krilla::paint::LineCap::Round,
designcraft_doc::Cap::Projecting => krilla::paint::LineCap::Square,
};
let kind = self.doc.stroke_kind(&st.kind);
// Stripes, wavy and hash strokes: a filled outline (gap colour under it).
if let Some(o) = kind.outline(bp, st.weight, 0.05)
&& let Some(op) = to_path(&o)
{
s.set_stroke(None);
if let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
&& st.gap_swatch != designcraft_color::swatch::NONE
{
s.set_stroke(Some(Stroke { paint: g.into(), width: st.weight as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.set_fill(None);
s.draw_path(path);
s.set_stroke(None);
}
s.set_fill(Some(Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&op);
s.set_fill(None);
return;
}
// Viewers expand dashes too: a degenerate pattern draws solid, as on screen.
let length = designcraft_doc::path_length_bound(bp);
let dash = match &kind {
StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
Some(StrokeDash { array: pat, offset: 0.0 })
}
StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => {
cap = krilla::paint::LineCap::Round;
Some(StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 })
}
_ => None,
};
let arrows = designcraft_doc::arrow::apply(bp, st, closed);
let trimmed = arrows.as_ref().and_then(|a| to_path(&a.0));
let path = trimmed.as_ref().unwrap_or(path);
let aligned = closed && st.align != StrokeAlign::Center;
let width = if aligned { st.weight * 2.0 } else { st.weight };
let mut pushes = 0;
match st.align {
StrokeAlign::Inside if closed => {
s.push_clip_path(path, &FillRule::NonZero);
pushes += 1;
}
StrokeAlign::Outside if closed => {
// Clip to everything outside the path: a big frame plus the path, even-odd.
let b = bp.bounding_box().inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
let mut outside = b.to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(&outside) {
s.push_clip_path(&p, &FillRule::EvenOdd);
pushes += 1;
}
}
_ => {}
}
s.set_fill(None);
if dash.is_some()
&& pushes == 0
&& let Some(g) = self.swatch_color(&st.gap_swatch, st.gap_tint)
{
// Gap colour under dashes and dots.
s.set_stroke(Some(Stroke { paint: g.into(), width: width as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.draw_path(path);
}
s.set_stroke(Some(Stroke {
paint: paint.clone(),
width: width as f32,
miter_limit: st.miter_limit.max(1.0) as f32,
line_cap: cap,
line_join: match st.join {
designcraft_doc::Join::Miter => krilla::paint::LineJoin::Miter,
designcraft_doc::Join::Round => krilla::paint::LineJoin::Round,
designcraft_doc::Join::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity: NormalizedF32::ONE,
dash,
}));
s.draw_path(path);
s.set_stroke(None);
for _ in 0..pushes {
s.pop();
}
for h in arrows.iter().flat_map(|a| &a.1) {
let Some(p) = to_path(&h.path) else { continue };
match h.outline {
Some(w) => {
s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
s.draw_path(&p);
s.set_stroke(None);
}
None => {
s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
s.draw_path(&p);
s.set_fill(None);
}
}
}
}
/// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the
/// export fails at the end with `VersionMismatch(Pdf17)`), and PDF/X-4 is PDF 1.6 while
/// 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 and catalog `/Version` relabelled, as
/// `qpdf --force-version=1.6` does). A newer
/// file is left out and the export fails naming it ([`Exporter::check_placed_versions`]).
/// 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));
return None;
};
let max = match self.version {
PdfVersion::Pdf14 => V::Pdf14,
PdfVersion::Pdf15 => V::Pdf15,
PdfVersion::Pdf16 => V::Pdf16,
PdfVersion::Pdf17 => V::Pdf17,
PdfVersion::Pdf20 => V::Pdf20,
};
let have = pdf.version();
if have <= max {
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
if have == V::Pdf17
&& max == V::Pdf16
&& let bytes = relabel_pdf_header(&asset.data, b"%PDF-1.7", b"%PDF-1.6").unwrap_or_else(|| asset.data.to_vec())
&& let Ok(pdf) = krilla::pdf::Pdf::new(Arc::new(relabel_catalog_version(bytes)))
&& pdf.version() <= max
{
self.warn(format!("{}: a PDF 1.7 placed in a PDF 1.6 file, embedded as PDF 1.6", asset.name));
let pages = pdf.pages().len();
return Some((krilla::pdf::PdfDocument::new(Arc::new(pdf)), pages));
}
let v = match have {
V::Pdf17 => "PDF 1.7",
V::Pdf20 => "PDF 2.0",
_ => "a newer PDF",
};
self.too_new.push(format!("{} is {v}", asset.name));
None
}
/// Fail with the names of placed PDFs too new for this export (see [`Exporter::placed_pdf`]).
fn check_placed_versions(&self) -> Result<()> {
if self.too_new.is_empty() {
return Ok(());
}
let v = self.version.as_str();
Err(PdfError::Write(format!(
"a placed PDF is newer than the {v} this export writes ({}): save it as {v} or older and place it again",
self.too_new.join(", ")
)))
}
fn load_image(&mut self, id: AssetId) -> Option<Image> {
if let Some(i) = self.images.get(&id) {
return i.clone();
}
let asset = self.doc.assets.get(&id)?.clone();
let data = asset.data.clone();
if let Some(converted) = self.images_by_content.get(&data).cloned() {
if let Some(warning) = converted.warning {
self.warn(format!("{}: {warning}", asset.name));
}
self.images.insert(id, Some(converted.image.clone()));
return Some(converted.image);
}
let fmt = image::guess_format(&data).ok();
#[cfg(test)]
{
self.image_conversions += 1;
}
let mut warning = None;
let mode = RasterMode::for_standard(self.opts.standard);
let img = match fmt {
Some(image::ImageFormat::Jpeg) => jpeg(data.clone().into(), || embedded_icc(&data), mode),
// A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print
// as four-colour black). PDF/A exports are RGB only.
Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data, mode) {
Some(img) => Some(img),
None => {
warning = Some(CMYK_FALLBACK_WARNING);
self.warn(format!("{}: {CMYK_FALLBACK_WARNING}", asset.name));
lossless(&data, fmt, mode)
}
},
_ if self.opts.compress_images => recompress(&data, mode).or_else(|| lossless(&data, fmt, mode)),
_ => lossless(&data, fmt, mode),
};
if img.is_none() {
if data.is_empty() {
// A linked file that wasn't found when the document was opened: nothing to decode.
let at = asset.link.as_deref().map(|l| format!(" ({l})")).unwrap_or_default();
self.warn(format!("image `{}` is missing{at} and was skipped", asset.name));
} else {
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
}
}
if let Some(image) = &img {
// Share the original encoded payload. HashMap checks exact bytes after hashing,
// so collisions cannot merge distinct images or their diagnostics.
self.images_by_content.insert(data, ConvertedImage { image: image.clone(), warning });
}
self.images.insert(id, img.clone());
img
}
/// Draw a placed graphic, inside its clipping path when it has one.
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
match g.clip_region().map(|(region, even_odd)| (to_path(&region), even_odd)) {
Some((Some(clip), even_odd)) => {
s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero });
self.graphic_unclipped(s, g);
s.pop();
}
// A degenerate outline shows nothing, or everything when inverted.
Some((None, even_odd)) => {
if even_odd {
self.graphic_unclipped(s, g);
}
}
None => self.graphic_unclipped(s, g),
}
}
fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
// Video / sound without a poster: a dark frame with a play mark.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_doc::media_kind(&asset.mime).is_some()
{
let (w, h) = g.size;
let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h) * 0.18);
let mut tri = BezPath::new();
tri.move_to((cx - r * 0.6, cy - r));
tri.line_to((cx + r, cy));
tri.line_to((cx - r * 0.6, cy + r));
tri.close_path();
s.push_transform(&tf(g.xf));
s.set_stroke(None);
for (bp, c) in [(Rect::new(0.0, 0.0, w, h).to_path(0.1), rgb::Color::new(48, 48, 52)), (tri, rgb::Color::new(235, 235, 235))] {
if let Some(p) = to_path(&bp) {
s.set_fill(Some(Fill { paint: c.into(), ..Default::default() }));
s.draw_path(&p);
}
}
s.set_fill(None);
s.pop();
return;
}
// Placed PDFs go in as vectors (the page as a form XObject).
let doc = self.doc;
if let Some(asset) = doc.assets.get(&g.asset)
&& asset.data.starts_with(b"%PDF")
{
// 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 => {
let p = self.placed_pdf(asset);
self.pdfs.insert(g.asset, p.clone());
p
}
};
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;
}
// Placed SVGs go in as vectors too.
if let Some(asset) = self.doc.assets.get(&g.asset)
&& designcraft_images::is_svg(&asset.data)
{
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
let tree = self.svgs.entry(g.asset).or_insert_with(|| designcraft_images::svg_tree(&asset.data).map(Arc::new)).clone();
match tree {
Some(tree) => {
use krilla_svg::SurfaceExt;
s.push_transform(&tf(g.xf));
if s.draw_svg(&tree, size, krilla_svg::SvgSettings::default()).is_none() {
self.warn(format!("{}: parts of the placed SVG could not be drawn", asset.name));
}
s.pop();
}
None => self.warn(format!("{}: can't read the placed SVG", asset.name)),
}
return;
}
let Some(img) = self.load_image(g.asset) else { return };
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
s.push_transform(&tf(g.xf));
s.draw_image(img, size);
s.pop();
}
}
/// `data` with its `%PDF-x.y` header (within the first 1024 bytes, where readers look for it)
/// changed from `from` to `to`, both the same length. `None` when the header isn't `from`.
fn relabel_pdf_header(data: &[u8], from: &[u8], to: &[u8]) -> Option<Vec<u8>> {
if from.len() != to.len() {
return None;
}
let head = data.get(..data.len().min(1024))?;
let at = head.windows(from.len()).position(|w| w == from)?;
let mut out = data.to_vec();
out.get_mut(at..at + to.len())?.copy_from_slice(to);
Some(out)
}
/// `data` with every `/Version /1.7` changed to `/Version /1.6` in place, so the xref offsets
/// hold. The catalog's `/Version` wins over the header (TCPDF writes both), so relabelling the
/// header alone leaves the file a PDF 1.7. A catalog inside a compressed object stream is left as
/// it is, and the version check still names the file.
fn relabel_catalog_version(mut data: Vec<u8>) -> Vec<u8> {
const KEY: &[u8] = b"/Version";
let mut from = 0;
while let Some(at) = data.get(from..).and_then(|rest| rest.windows(KEY.len()).position(|w| w == KEY)) {
let mut i = from + at + KEY.len();
while data.get(i).is_some_and(|b| b" \t\r\n\x0c\0".contains(b)) {
i += 1;
}
let ends = data.get(i + 4).is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'.');
if data.get(i..i + 4) == Some(b"/1.7".as_slice())
&& ends
&& let Some(b) = data.get_mut(i + 3)
{
*b = b'6';
}
from = i;
}
data
}
/// The sRGB profile (ICC v2.1) that untagged RGB images carry in PDF/X-4.
pub(crate) static SRGB_ICC: &[u8] = include_bytes!("../../../assets/icc/sRGB-v2-magic.icc");
/// How raster images are written for the export's standard.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct RasterMode {
/// `/Interpolate true`: smoother upscaling in viewers. PDF/X-4 and PDF/A don't allow it.
interpolate: bool,
/// Give RGB images with no ICC profile of their own the sRGB profile: under PDF/X-4's CMYK
/// output intent, RGB must be colour-managed.
tag_rgb: bool,
}
impl RasterMode {
fn for_standard(standard: Standard) -> Self {
Self { interpolate: standard == Standard::None, tag_rgb: standard == Standard::PdfX4 }
}
}
/// The ICC profile embedded in an image file, if it has one.
fn embedded_icc(data: &[u8]) -> Option<Vec<u8>> {
use image::ImageDecoder;
let mut dec = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_decoder().ok()?;
dec.icc_profile().ok().flatten()
}
/// Whether an ICC profile describes RGB (its colour space signature, header bytes 16 to 20).
fn is_rgb_icc(icc: &[u8]) -> bool {
icc.get(16..20) == Some(b"RGB ".as_slice())
}
/// 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.
fn jpeg(data: krilla::Data, icc: impl FnOnce() -> Option<Vec<u8>>, mode: RasterMode) -> Option<Image> {
if !mode.tag_rgb {
return Image::from_jpeg(data, mode.interpolate).ok();
}
let icc: krilla::Data = match icc() {
Some(own) => own.into(),
None => SRGB_ICC.into(),
};
Image::from_jpeg_with_icc(data, Some(icc), mode.interpolate).ok()
}
/// Whether krilla would write this PNG, GIF or WebP as untagged RGB: not grey, and no profile of
/// its own.
fn untagged_rgb(data: &[u8]) -> bool {
use image::ImageDecoder;
let Some(mut dec) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok()) else {
return false;
};
let grey = matches!(dec.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16);
!grey && dec.icc_profile().ok().flatten().is_none()
}
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>, mode: RasterMode) -> Option<Image> {
// krilla reads PNG, GIF and WebP lazily: a damaged file is accepted here and fails the whole
// export when the PDF is written. Decoding it first means it is skipped, like any other
// image that can't be decoded.
let rgba = designcraft_images::decode_rgba(data)?;
let i = mode.interpolate;
let direct = match fmt {
Some(image::ImageFormat::Png | image::ImageFormat::Gif | image::ImageFormat::WebP) if mode.tag_rgb && untagged_rgb(data) => None,
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), i).ok(),
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), i).ok(),
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), i).ok(),
_ => None,
};
direct.or_else(|| {
// 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());
}
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
}
/// An RGB raster tagged with the sRGB profile, for PDF/X-4.
#[derive(Clone, Hash)]
struct SrgbImage {
rgb: Arc<Vec<u8>>,
/// Only when some pixel is not opaque.
alpha: Option<Arc<Vec<u8>>>,
size: (u32, u32),
}
impl SrgbImage {
fn new(rgba: image::RgbaImage) -> Option<Self> {
let size = rgba.dimensions();
let pixels = usize::try_from(size.0).ok()?.checked_mul(usize::try_from(size.1).ok()?)?;
let mut rgb = Vec::with_capacity(pixels.checked_mul(3)?);
let mut alpha = Vec::with_capacity(pixels);
for p in rgba.pixels() {
rgb.extend_from_slice(&p.0[..3]);
alpha.push(p.0[3]);
}
let alpha = alpha.iter().any(|a| *a < 255).then(|| Arc::new(alpha));
Some(Self { rgb: Arc::new(rgb), alpha, size })
}
}
impl krilla::image::CustomImage for SrgbImage {
fn color_channel(&self) -> &[u8] {
&self.rgb
}
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]> {
Some(SRGB_ICC)
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Rgb
}
}
/// 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
/// convert 100% K to four-colour black.
#[derive(Clone, Hash)]
struct CmykImage(Arc<designcraft_images::CmykRaster>);
impl krilla::image::CustomImage for CmykImage {
fn color_channel(&self) -> &[u8] {
&self.0.cmyk
}
fn alpha_channel(&self) -> Option<&[u8]> {
self.0.alpha.as_deref()
}
fn bits_per_component(&self) -> krilla::image::BitsPerComponent {
krilla::image::BitsPerComponent::Eight
}
fn size(&self) -> (u32, u32) {
(self.0.width, self.0.height)
}
fn icc_profile(&self) -> Option<&[u8]> {
None
}
fn color_space(&self) -> krilla::image::ImageColorspace {
krilla::image::ImageColorspace::Cmyk
}
}
/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK.
fn cmyk_tiff(data: &[u8], mode: RasterMode) -> Option<Image> {
let r = designcraft_images::decode_cmyk_tiff(data)?;
// krilla panics when the channel lengths don't match the size.
let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?;
if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) {
return None;
}
Image::from_custom(CmykImage(Arc::new(r)), mode.interpolate).ok()
}
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
/// With `tag_rgb`, the JPEG keeps the source's RGB profile, or gets sRGB.
fn recompress(data: &[u8], mode: RasterMode) -> Option<Image> {
let img = image::load_from_memory(data).ok()?;
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)
}
#[cfg(test)]
mod image_content_cache_tests {
use super::*;
use designcraft_doc::Asset;
fn png(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(w, h, image::Rgba(color));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
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(), data: Arc::new(bytes), ..Default::default() }));
id
}
fn exporter<'a>(doc: &'a Document, cache: &'a Cache, opts: &'a PdfOptions) -> Exporter<'a> {
Exporter {
drawing_items: Vec::new(),
doc,
cache,
opts,
clip: Rect::ZERO,
warnings: Vec::new(),
images: HashMap::new(),
images_by_content: ImageContentCache::default(),
image_conversions: 0,
pdfs: HashMap::new(),
version: PdfVersion::Pdf17,
too_new: Vec::new(),
svgs: HashMap::new(),
fonts: HashMap::new(),
reverse_cmaps: HashMap::new(),
rgb_only: false,
tags: Vec::new(),
story_tags: HashMap::new(),
tag_story: None,
text_area: None,
para_tags: HashMap::new(),
}
}
#[test]
fn exact_bytes_reuse_conversion_under_real_hash_collisions() {
use std::hash::{BuildHasher, Hasher};
let mut d = Document::new(&Default::default());
let red = png(2, 1, [255, 0, 0, 255]);
let blue = png(1, 2, [0, 0, 255, 128]);
let a1 = asset(&mut d, red.clone(), "a1");
let b1 = asset(&mut d, blue.clone(), "b1");
let a2 = asset(&mut d, red, "a2");
let b2 = asset(&mut d, blue, "b2");
assert!(!Arc::ptr_eq(&d.assets[&a1].data, &d.assets[&a2].data));
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
let mut h1 = ex.images_by_content.hasher().build_hasher();
h1.write(&d.assets[&a1].data);
let mut h2 = ex.images_by_content.hasher().build_hasher();
h2.write(&d.assets[&b1].data);
assert_eq!(h1.finish(), h2.finish());
assert_ne!(d.assets[&a1].data, d.assets[&b1].data);
for (id, count, size) in [(a1, 1, (2, 1)), (b1, 2, (1, 2)), (a2, 2, (2, 1)), (a1, 2, (2, 1)), (b2, 2, (1, 2))] {
assert_eq!(ex.load_image(id).unwrap().size(), size);
assert_eq!(ex.image_conversions, count, "count actual converter executions, not krilla resource equality");
}
assert_eq!(ex.images.len(), 4);
assert_eq!(ex.images_by_content.len(), 2);
let first_key = ex.images_by_content.keys().find(|key| key.as_slice() == d.assets[&a1].data.as_slice()).unwrap();
assert!(Arc::ptr_eq(first_key, &d.assets[&a1].data), "cache keys share the original encoded payload");
ex.images_by_content.clear();
assert_eq!(ex.load_image(a1).unwrap().size(), (2, 1));
assert_eq!(ex.image_conversions, 2, "the AssetId cache remains the first lookup");
assert!(ex.images_by_content.is_empty());
}
#[test]
fn successful_content_cache_does_not_suppress_failure_names() {
let mut d = Document::new(&Default::default());
let a = asset(&mut d, vec![0, 1, 2, 3], "broken-a.png");
let b = asset(&mut d, vec![0, 1, 2, 3], "broken-b.png");
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_none());
assert!(ex.load_image(b).is_none());
assert!(ex.load_image(a).is_none());
assert_eq!(ex.image_conversions, 2);
assert!(ex.images_by_content.is_empty());
assert_eq!(ex.warnings.len(), 2);
assert!(ex.warnings[0].contains("broken-a.png"));
assert!(ex.warnings[1].contains("broken-b.png"));
}
#[test]
fn content_cache_is_scoped_to_each_immutable_options_context() {
let mut d = Document::new(&Default::default());
let data = png(2, 2, [240, 20, 30, 255]);
let a = asset(&mut d, data.clone(), "a");
let b = asset(&mut d, data, "b");
let cache = Cache::new();
for compress_images in [false, true, false] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
}
}
#[test]
fn successful_cmyk_fallback_replays_each_asset_name_once_and_converts_once() {
let bytes = crate::image_fixtures::cmyk_tiff(None, true);
assert!(designcraft_images::is_cmyk_tiff(&bytes));
assert!(designcraft_images::decode_cmyk_tiff(&bytes).is_none());
assert!(designcraft_images::decode_rgba(&bytes).is_some());
let mut d = Document::new(&Default::default());
let a = asset(&mut d, bytes.clone(), "ink-a.tif");
let b = asset(&mut d, bytes, "ink-b.tif");
let cache = Cache::new();
for compress_images in [false, true] {
let opts = PdfOptions { compress_images, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
for id in [a, b, a, b] {
assert_eq!(ex.load_image(id).unwrap().size(), (2, 1));
}
assert_eq!(ex.image_conversions, 1);
assert_eq!(ex.images_by_content.len(), 1);
assert_eq!(ex.warnings, [format!("ink-a.tif: {CMYK_FALLBACK_WARNING}"), format!("ink-b.tif: {CMYK_FALLBACK_WARNING}")]);
}
}
#[test]
fn cmyk_alpha_and_rgb_only_context_keep_conversion_counts_and_failures() {
let cache = Cache::new();
for alpha in [None, Some(2), Some(1)] {
let mut d = Document::new(&Default::default());
let bytes = crate::image_fixtures::cmyk_tiff(alpha, false);
// RGB-only exports get the RGB the screen shows.
let screen_rgb: Vec<u8> =
designcraft_images::decode_rgba(&bytes).map_or_else(Vec::new, |img| img.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect());
let a = asset(&mut d, bytes.clone(), "a.tif");
let b = asset(&mut d, bytes, "b.tif");
for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX4, Standard::PdfA2b, Standard::None] {
let opts = PdfOptions { standard, ..Default::default() };
let mut ex = exporter(&d, &cache, &opts);
ex.rgb_only = standard == Standard::PdfA2b;
// Unassociated alpha (2) decodes as inks, and as RGB for RGB-only exports.
// Associated alpha is rejected by both paths: retain both failure names.
let succeeds = alpha.is_none() || alpha == Some(2);
let first = ex.load_image(a);
assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}");
if alpha.is_none() {
let (space, samples) = image_color_samples(first.unwrap());
assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" });
assert_eq!(samples, if ex.rgb_only { screen_rgb.clone() } else { crate::image_fixtures::INKS.to_vec() });
}
assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}");
assert_eq!(ex.load_image(a).is_some(), succeeds);
assert_eq!(ex.image_conversions, if succeeds { 1 } else { 2 });
assert_eq!(ex.images_by_content.len(), usize::from(succeeds));
if succeeds {
assert!(ex.warnings.is_empty());
} else {
for name in ["a.tif", "b.tif"] {
assert!(ex.warnings.contains(&format!("image `{name}` could not be decoded and was skipped")));
assert_eq!(ex.warnings.contains(&format!("{name}: {CMYK_FALLBACK_WARNING}")), !ex.rgb_only);
}
}
}
}
}
// Serialize the converted raster without an archival validator so the RGB-only pixel
// contract stays independent of main's existing PDF/A interpolation validation failure.
fn image_color_samples(image: Image) -> (String, Vec<u8>) {
use hayro_syntax::object::Name;
let mut pdf = krilla::Document::new();
let size = Size::from_wh(20.0, 20.0).unwrap();
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut surface = page.surface();
surface.draw_image(image, size);
surface.finish();
page.finish();
let bytes = pdf.finish().unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let stream = pdf
.objects()
.into_iter()
.filter_map(|o| o.into_stream())
.find(|s| s.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.unwrap();
(stream.dict().get::<Name>("ColorSpace").unwrap().as_str().to_owned(), stream.decoded().unwrap().into_owned())
}
#[test]
fn content_cache_owns_only_existing_payloads_and_drops_with_exporter() {
let mut d = Document::new(&Default::default());
let bytes = png(2, 1, [20, 30, 40, 255]);
let a = asset(&mut d, bytes.clone(), "a.png");
let b = asset(&mut d, bytes, "b.png");
let payload = Arc::downgrade(&d.assets[&a].data);
let duplicate = Arc::downgrade(&d.assets[&b].data);
let cache = Cache::new();
let opts = PdfOptions::default();
let mut ex = exporter(&d, &cache, &opts);
assert!(ex.load_image(a).is_some());
assert!(ex.load_image(b).is_some());
assert_eq!(duplicate.strong_count(), 1, "a content hit must not retain the second encoded allocation");
let key = ex.images_by_content.keys().next().unwrap();
assert!(Arc::ptr_eq(key, &d.assets[&a].data));
drop(ex);
drop(d);
assert!(payload.upgrade().is_none(), "no converted image or encoded key outlives this exporter");
assert!(duplicate.upgrade().is_none());
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod unwind_tests {
use super::*;
use std::cell::Cell;
thread_local! {
static FAIL_NEXT_SURFACE: Cell<bool> = const { Cell::new(false) };
}
// Fail with live backend drawing state, so a missing guard or an unpatched destructor
// makes the child fail. Thread-local state keeps unrelated export tests independent.
#[allow(clippy::panic)] // Test-only fault injection exercises recovery with a live surface.
pub(super) fn inject(surface: &mut krilla::surface::Surface<'_>) {
if FAIL_NEXT_SURFACE.replace(false) {
surface.push_transform(&krilla::geom::Transform::from_translate(2.0, 3.0));
panic!("synthetic export drawing failure");
}
}
#[test]
fn export_entry_points_recover_in_a_subprocess() {
const CHILD: &str = "DESIGNCRAFT_PDF_EXPORT_PANIC_PROBE";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "export::unwind_tests::export_entry_points_recover_in_a_subprocess", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success(), "export child failed: {:?}\n{}", output.status, String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).contains("all three export entry points recovered"));
return;
}
let doc = Document::new(&designcraft_doc::build::NewDocument { pages: 1, ..Default::default() });
let cache = Cache::new();
let source = export_pdf(&doc, &cache, &Default::default()).unwrap();
let parts = [(source, vec![(612.0, 792.0)])];
for entry in ["document", "booklet", "merge"] {
let export = || match entry {
"document" => export_pdf(&doc, &cache, &Default::default()),
"booklet" => export_booklet(&doc, &cache, &Default::default()).map(|report| report.bytes),
_ => merge_pdfs(&parts, None),
};
FAIL_NEXT_SURFACE.set(true);
assert!(matches!(export(), Err(PdfError::Write(message)) if message.contains("synthetic export drawing failure")), "{entry}");
let fresh = export().unwrap();
assert!(!hayro_syntax::Pdf::new(Arc::new(fresh)).unwrap().pages().is_empty(), "fresh {entry} export must still work");
}
println!("all three export entry points recovered");
}
}