Merge pull request #5908 from mockydevs/feat/pdf-export-url-links-5785

PDF and .ai export: objects' URLs become clickable links (#5785)
This commit is contained in:
BFlat
2026-11-06 17:01:56 +08:00
committed by GitHub
9 changed files with 270 additions and 3 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Attributes",
[],
None,
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG, PDF and .ai export link the object), note?: string, ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
has_doc,
set
),
+1
View File
@@ -33,6 +33,7 @@ use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard};
mod entry;
mod exporter;
mod images;
mod links;
mod nodes;
mod paint;
mod text;
+11
View File
@@ -79,6 +79,16 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
// In PDF 1.6 and later files that aren't PDF/X files, a page with a side longer than
// [`MAX_PAGE_SIDE`] is written in larger units; other files have every page in points.
let scales = set.compatibility.has_user_unit() && !set.standard.is_pdfx();
// PDF/X files have no annotations over the art (ISO 15930: only printer's mark and trap
// network annotations, outside the bleed box): objects' URLs aren't written as links there.
let links = !set.standard.is_pdfx();
let mut linked = false;
for layer in doc.layers.iter().filter(|_| !links) {
layer.walk(&mut |n| linked |= n.url().is_some());
}
if linked {
warnings.push(format!("{} files have no links: the objects' URLs aren't clickable", set.standard.label()));
}
let mut units = Vec::with_capacity(pages.len());
let total = pages.len() as u64;
for (written, (i, boxes)) in pages.into_iter().enumerate() {
@@ -107,6 +117,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
clip: m != r,
marks: art.as_ref(),
negative: false,
links,
};
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
}
+121
View File
@@ -0,0 +1,121 @@
//! The objects' URLs (Window › Attributes) as link annotations: a rectangle over each linked
//! object's bounds on the page that opens its URL (ISO 32000-1 §12.5.6.5 Link Annotations, with a
//! URI action, §12.6.4.7).
use super::{Affine, Exporter, Node, NodeKind, Rect, krilla};
/// Most links written on one page: the rest are left out with a warning.
pub(super) const MAX_LINKS: usize = 10_000;
/// The warning for the links left out past [`MAX_LINKS`].
pub(super) const TOO_MANY_LINKS: &str = "a page has more linked objects than a PDF page keeps links for: the rest aren't clickable";
/// Does a link open `uri`? Web and mail URLs only, the ones the app's Browser button opens: a URL
/// can come from an imported file, and a `javascript:` or `file:` one has no place in a PDF.
fn opens(uri: &str) -> bool {
let scheme = uri.trim().split_once(':').map_or("", |(s, _)| s);
["http", "https", "mailto"].iter().any(|k| scheme.eq_ignore_ascii_case(k))
}
/// A link to write: the URI it opens, and the area it covers in page space (y down).
pub(super) struct Link {
pub uri: String,
pub rect: Rect,
}
impl Exporter<'_> {
/// The links of the art drawn on a page: each linked object that shows (on a layer that
/// prints, or any layer when non-printing layers are written; not on a template layer), over
/// its bounds within the clipping groups it is in and within `area` (document space, the art
/// drawn), mapped onto the page by `to_page`. A group's link comes before its objects', so an
/// object's own link lies over it. The second value: links were left out past [`MAX_LINKS`].
pub(super) fn links(&self, area: Rect, to_page: Affine) -> (Vec<Link>, bool) {
let mut links = vec![];
let mut more = false;
for layer in &self.doc.layers {
self.link_node(layer, area, to_page, &mut links, &mut more);
}
(links, more)
}
fn link_node(&self, n: &Node, within: Rect, to_page: Affine, links: &mut Vec<Link>, more: &mut bool) {
if !n.visible || matches!(n.kind, NodeKind::Path { guide: true, .. }) {
return;
}
if let NodeKind::Layer { template, printable, .. } = n.kind
&& (template || !(printable || self.non_printing))
{
return;
}
let bounds = n.visual_bounds_with(&self.symbols).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()));
if let (Some(uri), Some(b)) = (n.url().filter(|u| opens(u)), bounds) {
let shown = b.intersect(within);
if shown.width() > 0.0 && shown.height() > 0.0 {
let rect = to_page.transform_rect_bbox(shown);
if [rect.x0, rect.y0, rect.x1, rect.y1].iter().any(|v| !v.is_finite()) {
// Off any page a reader can show: no link.
} else if links.len() < MAX_LINKS {
links.push(Link { uri: uri_bytes(uri), rect });
} else {
*more = true;
}
}
}
// What a clipping group holds shows only inside its clipping path's bounds.
let within = match bounds {
Some(b) if n.clips() => b.intersect(within),
None if n.clips() => return,
_ => within,
};
if within.width() <= 0.0 || within.height() <= 0.0 {
return;
}
for child in n.children().into_iter().flatten() {
self.link_node(child, within, to_page, links, more);
}
}
}
/// `uri` as a PDF URI (ISO 32000-1 §12.6.4.7: 7-bit ASCII): spaces, control characters and
/// non-ASCII bytes percent-encoded (RFC 3986 §2.1), leading and trailing spaces dropped.
fn uri_bytes(uri: &str) -> String {
let mut out = String::with_capacity(uri.len());
for b in uri.trim().bytes() {
if b.is_ascii_graphic() {
out.push(char::from(b));
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The annotation of `link`: a borderless rectangle that opens its URI.
pub(super) fn annotation(link: Link) -> Option<krilla::annotation::Annotation> {
let r = link.rect;
let rect = krilla::geom::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32)?;
let target = krilla::annotation::Target::Action(krilla::action::LinkAction::new(link.uri.clone()).into());
Some(krilla::annotation::Annotation::new_link(krilla::annotation::LinkAnnotation::new(rect, target), Some(link.uri)))
}
#[cfg(test)]
mod tests {
use super::{opens, uri_bytes};
#[test]
fn only_web_and_mail_urls_become_links() {
for ok in ["https://example.com/a", "HTTP://example.com", "mailto:someone@example.com", " https://example.com "] {
assert!(opens(ok), "{ok}");
}
for refused in ["javascript:alert(1)", "file:///etc/passwd", "smb://host/share", "ms-settings:", "example.com", ""] {
assert!(!opens(refused), "{refused}");
}
}
#[test]
fn uris_are_written_in_ascii() {
assert_eq!(uri_bytes(" https://example.com/a b "), "https://example.com/a%20b");
assert_eq!(uri_bytes("https://example.com/café"), "https://example.com/caf%C3%A9");
assert_eq!(uri_bytes("mailto:a@b.c?subject=x\ny"), "mailto:a@b.c?subject=x%0Ay");
}
}
+11
View File
@@ -51,6 +51,8 @@ pub(crate) struct Sheet<'a> {
pub marks: Option<&'a Node>,
/// Invert the page: paper black, ink clear (a film negative).
pub negative: bool,
/// The objects' URLs are written as links over them ([`super::links`]).
pub links: bool,
}
impl Writer {
@@ -190,6 +192,15 @@ impl Writer {
s.pop();
}
s.finish();
if fits && sheet.links {
let (links, more) = ex.links(r, sheet.view * sheet.place);
for annotation in links.into_iter().filter_map(super::links::annotation) {
page.add_annotation(annotation);
}
if more {
ex.warn(super::links::TOO_MANY_LINKS);
}
}
page.finish();
Ok(())
}
+2
View File
@@ -338,6 +338,8 @@ impl PlannedSheet {
clip: true,
marks: self.marks.as_ref(),
negative: self.negative,
// A printed sheet has no links to click.
links: false,
}
}
}
+1
View File
@@ -27,6 +27,7 @@ mod import_lines;
mod import_options;
mod import_overprint;
mod layers;
mod links;
pub(super) mod live_text;
mod marks;
mod mesh_export;
+119
View File
@@ -0,0 +1,119 @@
//! Objects with a URL (Window › Attributes) export as link annotations over their bounds that
//! open it; hidden ones and PDF/X files have none.
use std::sync::Arc;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Dict, Name};
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeId};
use vectorcraft_geom::{Rect, shapes};
use super::{add, doc, rect_node};
use crate::*;
/// A rectangle over `r` linking to `url`.
fn linked(r: Rect, url: &str) -> Node {
let mut n = rect_node(r, Color::rgb(1.0, 0.0, 0.0));
n.edit_attrs(|a| a.url = url.into());
n
}
/// Each link annotation of each page: its URI and its rectangle (PDF space, y up).
fn links(pdf: &[u8]) -> Vec<Vec<(String, [f32; 4])>> {
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
file.pages()
.iter()
.map(|page| {
let Some(annots) = page.raw().get::<Array<'_>>(b"Annots") else { return vec![] };
annots
.iter::<Dict<'_>>()
.filter(|a| a.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Link"))
.map(|a| {
let action = a.get::<Dict<'_>>(b"A").unwrap();
assert_eq!(action.get::<Name<'_>>(b"S").unwrap().as_ref(), b"URI");
let uri = action.get::<craft_pdf::syntax::object::String<'_>>(b"URI").unwrap().as_bytes().to_vec();
let r: Vec<f32> = a.get::<Array<'_>>(b"Rect").unwrap().iter::<f32>().collect();
(String::from_utf8(uri).unwrap(), [r[0], r[1], r[2], r[3]])
})
.collect()
})
.collect()
}
fn near(a: [f32; 4], b: [f32; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
}
#[test]
fn a_linked_object_exports_a_link_over_its_bounds() {
let mut d = doc(200.0, 100.0);
add(&mut d, rect_node(Rect::new(0.0, 0.0, 5.0, 5.0), Color::rgb(0.0, 0.0, 1.0)));
add(&mut d, linked(Rect::new(10.0, 10.0, 50.0, 30.0), "https://example.com/a b"));
for bytes in [export(&d, &PdfOptions::default()).unwrap(), export(&d, &PdfOptions::uncompressed()).unwrap()] {
let pages = links(&bytes);
assert_eq!(pages.len(), 1);
let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") };
// In ASCII, as a PDF URI is (the space percent-encoded).
assert_eq!(uri, "https://example.com/a%20b");
// y up from the page's bottom edge: 100 − 30 to 100 − 10.
assert!(near(*rect, [10.0, 70.0, 50.0, 90.0]), "{rect:?}");
}
}
#[test]
fn hidden_and_unprinted_objects_have_no_links_and_clipping_trims_them() {
let mut d = doc(200.0, 100.0);
let mut hidden = linked(Rect::new(10.0, 10.0, 50.0, 30.0), "https://example.com/hidden");
hidden.visible = false;
add(&mut d, hidden);
// Clipped to x 20…40: the link covers only what shows.
let clip = Node::path(NodeId(0), shapes::rectangle(Rect::new(20.0, 0.0, 40.0, 100.0)), Default::default());
let mut group = Node::group(NodeId(0), vec![Arc::new(clip), Arc::new(linked(Rect::new(0.0, 40.0, 200.0, 60.0), "https://example.com/clipped"))]);
group.set_clips(true);
let group = d.reid(&group);
add(&mut d, group);
// Off the artboard: nothing to click.
add(&mut d, linked(Rect::new(300.0, 10.0, 350.0, 30.0), "https://example.com/off"));
let pages = links(&export(&d, &PdfOptions::default()).unwrap());
let [(uri, rect)] = pages[0].as_slice() else { panic!("one link: {pages:?}") };
assert_eq!(uri, "https://example.com/clipped");
assert!(near(*rect, [20.0, 40.0, 40.0, 60.0]), "{rect:?}");
// A layer that doesn't print leaves its links out with its art.
let mut d = doc(200.0, 100.0);
add(&mut d, linked(Rect::new(10.0, 10.0, 50.0, 30.0), "https://example.com/"));
if let vectorcraft_doc::NodeKind::Layer { printable, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
*printable = false;
}
assert_eq!(links(&export(&d, &PdfOptions::default()).unwrap()), vec![Vec::<(String, [f32; 4])>::new()]);
}
#[test]
fn each_page_links_its_own_artboard_objects() {
let mut d = doc(200.0, 100.0);
let mut second = d.artboards[0].clone();
second.id = 2;
second.rect = Rect::new(300.0, 0.0, 400.0, 100.0);
d.artboards.push(second);
add(&mut d, linked(Rect::new(310.0, 20.0, 330.0, 40.0), "mailto:a@example.com"));
let pages = links(&export(&d, &PdfOptions::default()).unwrap());
assert_eq!(pages[0], vec![]);
let [(uri, rect)] = pages[1].as_slice() else { panic!("one link: {pages:?}") };
assert_eq!(uri, "mailto:a@example.com");
assert!(near(*rect, [10.0, 60.0, 30.0, 80.0]), "{rect:?}");
}
#[test]
fn pdfx_files_have_no_links() {
let mut d: Document = doc(200.0, 100.0);
add(&mut d, linked(Rect::new(10.0, 10.0, 50.0, 30.0), "https://example.com/"));
let mut o = PdfOptions::default();
(o.settings.standard, o.settings.compatibility) = (Standard::PdfX4, Standard::PdfX4.version());
o.created = Some(1_700_000_000);
let report = export_with_report(&d, &o).unwrap();
assert_eq!(links(&report.bytes), vec![Vec::<(String, [f32; 4])>::new()]);
assert!(report.warnings.iter().any(|w| w.contains("URLs aren't clickable")), "{:?}", report.warnings);
// Without links, no such warning.
assert!(!export_with_report(&doc(200.0, 100.0), &o).unwrap().warnings.iter().any(|w| w.contains("URLs")));
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -152,7 +152,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) |
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
@@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged |
| 2026-11-06 | minor | SVG import: a gradient with `color-interpolation="linearRGB"` (on it, inherited, or from a style sheet) mixes its stops in linear light, as SVG 1.1 §11.7.1 asks: the document gradient gets extra stops wherever display-RGB mixing would stray by half a colour level or more, so black to white is about 188/255 mid-way instead of 128 (#5878). A gradient several objects share is mixed so on every one. Opacity still mixes evenly; at most 4096 stops per gradient. Tests `import::linear_rgb`, `gradients::linear_rgb_*` (crate `vectorcraft-svg`); scores unchanged |
| 2026-11-05 | minor | `.ai` import (#372): a path with a Calligraphic brush comes in as the path with the brush on its stroke (its weight scales the brush) when its art style's brush settings are the brush's own and its angle is on the page, else as its drawn look; a Calligraphic brush's Tilt, Bearing and Rotation codes come in as those channels instead of Random. Checked against a user's file of 6D Art Pen strokes (used locally, not committed); synthetic tests in `import::ai::tests` |
| 2026-11-05 | minor | PDF import: the stops of an axial or radial shading in a DeviceN space of one spot ink and process plates link to the ink's swatch and keep the plates (`GradientStop.plates` in the native format); PDF and EPS export write them in a DeviceN space again (#3235) |