Keep viewBox-only placed SVGs at viewBox size on IDML import

Fixes #950

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-14 19:42:32 +00:00
co-authored by Claude Haiku 5.5
parent d07cbbe94c
commit 2302b7ec7f
2 changed files with 84 additions and 3 deletions
+41 -3
View File
@@ -242,6 +242,40 @@ fn nums(s: &str) -> Vec<f64> {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
/// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as
/// none): InDesign places such a file at one viewBox unit to the point.
fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> {
use quick_xml::events::Event;
if data.iter().find(|b| !b.is_ascii_whitespace()) != Some(&b'<') {
return None;
}
let mut r = quick_xml::Reader::from_reader(data);
let mut buf = Vec::new();
let root = loop {
match r.read_event_into(&mut buf).ok()? {
Event::Start(e) | Event::Empty(e) => break e.into_owned(),
Event::Eof => return None,
_ => buf.clear(),
}
};
if root.local_name().as_ref() != b"svg" {
return None;
}
let attr = |name: &[u8]| {
root.attributes().with_checks(false).flatten().find(|a| a.key.as_ref() == name).and_then(|a| a.unescape_value().ok().map(|v| v.to_string()))
};
let absolute = |name: &[u8]| attr(name).is_some_and(|v| !v.trim().ends_with('%'));
if absolute(b"width") || absolute(b"height") {
return None;
}
let vb: Vec<f64> =
attr(b"viewBox")?.split(|c: char| c.is_whitespace() || c == ',').filter(|v| !v.is_empty()).filter_map(|v| v.parse().ok()).collect();
match vb[..] {
[_, _, w, h] if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 => Some((w, h)),
_ => None,
}
}
fn parse_affine(s: Option<&str>) -> Affine {
match s.map(nums) {
Some(v) if v.len() == 6 => Affine::new([v[0], v[1], v[2], v[3], v[4], v[5]]),
@@ -2366,7 +2400,7 @@ impl<'r> Importer<'r> {
let size = ((r - l).abs(), (b - t).abs());
let link = g.find("Link");
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
let graphic = |asset| {
let graphic = |asset, size| {
Content::Graphic(designcraft_doc::Graphic {
asset,
size,
@@ -2383,13 +2417,17 @@ impl<'r> Importer<'r> {
{
// Every placement of a linked file shares one asset; read the file once.
if let Some(id) = self.linked_assets.get(p) {
return graphic(*id);
let size = self.assets.get(id).and_then(|a| svg_viewbox_size(&a.data)).unwrap_or(size);
return graphic(*id, size);
}
if let Some(d) = (self.read_link)(p) {
data = d;
read_from = Some(p.clone());
}
}
// InDesign draws a viewBox-only SVG at one unit to the point: its GraphicBounds are the
// art's bounds, which can overhang the viewBox, so they must not size the viewBox.
let size = svg_viewbox_size(&data).unwrap_or(size);
let (mime, px) = sniff_image(&data);
let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") {
f if f.contains("JPEG") => "image/jpeg".into(),
@@ -2411,7 +2449,7 @@ impl<'r> Importer<'r> {
if let Some(p) = read_from {
self.linked_assets.insert(p, id);
}
graphic(id)
graphic(id, size)
}
fn link_threads(&mut self) {
+43
View File
@@ -108,3 +108,46 @@ fn one_file_placed_several_times_imports_as_one_asset() {
}
}
}
/// An `<SVG>` placement linking `uri`, whose GraphicBounds are `bounds` (left top right bottom).
fn svg(n: usize, uri: &str, bounds: [f64; 4]) -> String {
let [l, t, r, b] = bounds;
format!(
r#"<SVG Self="sv{n}" ItemTransform="1 0 0 1 0 0">
<Properties><GraphicBounds Left="{l}" Top="{t}" Right="{r}" Bottom="{b}"/></Properties>
<Link Self="lk{n}" LinkResourceURI="{uri}" LinkResourceFormat="$ID/SVG" StoredState="Normal"/>
</SVG>"#
)
}
#[test]
fn placed_svg_with_only_a_viewbox_keeps_its_viewbox_size() {
const OVERHANG: &str =
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><line x1="-10" y1="50" x2="0" y2="50" stroke="#000"/></svg>"##;
const SIZED: &str =
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"><rect width="200" height="100"/></svg>"#;
let reader = |p: &str| match p {
"/art/overhang.svg" => Some(OVERHANG.as_bytes().to_vec()),
"/art/sized.svg" => Some(SIZED.as_bytes().to_vec()),
_ => None,
};
// GraphicBounds are the art's bounds: 211 wide for the overhanging one, placed twice (the second
// reuses the first's asset); a sized SVG is still stretched onto its bounds.
let frames = vec![
frame(1, 0.0, 0.0, &svg(1, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])),
frame(2, 100.0, 0.0, &svg(2, "file:/art/overhang.svg", [-11.0, 0.0, 200.0, 100.0])),
frame(3, 200.0, 0.0, &svg(3, "file:/art/sized.svg", [-10.0, 0.0, 200.0, 100.0])),
];
let d = import_idml_with(&package(&frames), &reader).unwrap();
let mut gs: Vec<_> = d.spreads[0]
.items
.iter()
.filter_map(|i| match &i.content {
Content::Graphic(g) => Some((i.xf.translation().x, g.size, g.xf.translation().x)),
_ => None,
})
.collect();
gs.sort_by(|a, b| a.0.total_cmp(&b.0));
let got: Vec<_> = gs.into_iter().map(|(_, size, x)| (size, x)).collect();
assert_eq!(got, vec![((200.0, 100.0), -11.0), ((200.0, 100.0), -11.0), ((210.0, 100.0), -10.0)]);
}