mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 00:42:40 +00:00
Add craft-silhouette-studio: Silhouette Studio document reader (#805)
Bounded, fail-closed reader for .studio and .studio3 documents: outlines, line colours and page size in millimetres. Protected designs are refused.
This commit is contained in:
Generated
+8
@@ -1162,6 +1162,14 @@ dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-silhouette-studio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-svg-paths"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -51,6 +51,7 @@ craft-print = { path = "crates/print" }
|
||||
craft-proof = { path = "crates/proof" }
|
||||
craft-quit = { path = "crates/quit" }
|
||||
craft-settings = { path = "crates/settings" }
|
||||
craft-silhouette-studio = { path = "crates/silhouette-studio" }
|
||||
craft-svg-paths = { path = "crates/svg-paths" }
|
||||
craft-text = { path = "crates/text" }
|
||||
craft-tiff = { path = "crates/tiff" }
|
||||
|
||||
@@ -118,6 +118,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
app menu. Unsaved-document decisions and save prompts stay in each app.
|
||||
|
||||
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
|
||||
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
|
||||
- [`craft-svg-paths`](crates/svg-paths/src/lib.rs): SVG geometry reader: shapes,
|
||||
transforms, `<use>`/`<symbol>` instances and the CSS cascade as Bézier paths in millimetres, each
|
||||
tagged by the caller. Import only; not the shared SVG model.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "craft-silhouette-studio"
|
||||
description = "Bounded, fail-closed reader for Silhouette Studio .studio (v2) and .studio3 (v3) documents: outlines, line colours and page size"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Reader for Silhouette Studio documents: `.studio` (header `silhouette04;`) and
|
||||
//! `.studio3` (header `silhouette05;`).
|
||||
//!
|
||||
//! It reads every object's outline (open and closed paths, lines and Bézier curves, compound
|
||||
//! paths, text as outlines), each outline's line colour and the page size. Coordinates are
|
||||
//! millimetres from the page's top-left corner, y down, as the file stores them. Fills, layers
|
||||
//! and cut settings are not read.
|
||||
//!
|
||||
//! The layout comes from differential analysis of documents drawn and saved in Silhouette
|
||||
//! Studio: change one thing, save, compare the bytes. No code or binary was read.
|
||||
//!
|
||||
//! The reader is bounded and fails closed: input size and shape/point counts are capped, every
|
||||
//! length is checked against the bytes that are really there, and a body that is not in the
|
||||
//! plain layout known here (for example a protected store design) is rejected instead of
|
||||
//! guessed at. Records it does not understand are skipped. Depends on no other Craft crate.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use kurbo::{BezPath, Point, Vec2};
|
||||
|
||||
/// Largest file read (store designs and saved samples are far smaller).
|
||||
pub const MAX_FILE_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const MAX_SHAPES: usize = 100_000;
|
||||
pub const MAX_POINTS: usize = 2_000_000;
|
||||
/// Coordinates are millimetres; anything past a kilometre is corruption.
|
||||
const MAX_COORD_MM: f32 = 1.0e6;
|
||||
|
||||
/// Nodes the scan may parse in all, counting candidates that turn out not to be outlines:
|
||||
/// bytes that merely look like huge outlines cannot make the scan quadratic.
|
||||
const MAX_SCANNED_NODES: usize = 4 * MAX_POINTS;
|
||||
|
||||
const MAGIC_LEN: usize = 13;
|
||||
/// `.studio3` files start with a 1024-byte header holding a table of section lengths.
|
||||
const V3_HEADER_LEN: usize = 1024;
|
||||
const V3_TABLE_START: usize = 0x10;
|
||||
const V3_TABLE_END: usize = 0x3e8;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum Error {
|
||||
#[error("not a Silhouette Studio document")]
|
||||
NotStudio,
|
||||
#[error("Silhouette Studio document version {version} is not supported yet")]
|
||||
Unsupported { version: String },
|
||||
/// The container parsed but its body is not plain records. Never decrypted.
|
||||
#[error("this design is protected or encrypted (for example a store purchase)")]
|
||||
Protected,
|
||||
#[error("file exceeds the 64 MiB import limit")]
|
||||
TooLarge,
|
||||
#[error("damaged Silhouette Studio document: {0}")]
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
/// Which container the bytes use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Version {
|
||||
/// `.studio`: the document body alone, header `silhouette04;`.
|
||||
V2,
|
||||
/// `.studio3`: a 1024-byte section table, then the body (header `silhouette05;`),
|
||||
/// a thumbnail PNG and further sections.
|
||||
V3,
|
||||
}
|
||||
|
||||
/// One object's outline: a subpath per contour, so a compound path stays one shape.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shape {
|
||||
/// Page millimetres from the top-left corner, y down.
|
||||
pub path: BezPath,
|
||||
/// The object's line colour (R, G, B), when its style record was found.
|
||||
pub line_color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// A document's shapes in file order, plus notes about what was skipped.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Imported {
|
||||
pub version: Version,
|
||||
/// Page width and height in millimetres; `None` when the page record is not as expected.
|
||||
pub page_mm: Option<(f64, f64)>,
|
||||
pub shapes: Vec<Shape>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Is this file name one this crate reads?
|
||||
pub fn is_studio_path(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
lower.ends_with(".studio") || lower.ends_with(".studio3")
|
||||
}
|
||||
|
||||
/// Cheap header check, for dispatch by content rather than extension.
|
||||
pub fn sniff(bytes: &[u8]) -> Option<Version> {
|
||||
match bytes.get(..MAGIC_LEN)? {
|
||||
b"silhouette04;" => Some(Version::V2),
|
||||
b"silhouette05;" => Some(Version::V3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `.studio` / `.studio3` document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, Error> {
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let bad = |what: &str| Error::Malformed(what.to_string());
|
||||
let magic = bytes.get(..MAGIC_LEN).ok_or(Error::NotStudio)?;
|
||||
if !magic.starts_with(b"silhouette") || magic.last() != Some(&b';') {
|
||||
return Err(Error::NotStudio);
|
||||
}
|
||||
let (version, body) = match magic {
|
||||
b"silhouette04;" => (Version::V2, bytes),
|
||||
// A bare body (header followed directly by the root record) is read as is.
|
||||
b"silhouette05;" if bytes.get(MAGIC_LEN) == Some(&1) => (Version::V3, bytes),
|
||||
b"silhouette05;" => (Version::V3, v3_body(bytes)?),
|
||||
_ => {
|
||||
let version = String::from_utf8_lossy(magic.get(10..12).unwrap_or_default());
|
||||
return Err(Error::Unsupported { version: version.into_owned() });
|
||||
}
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
let root = block(body, MAGIC_LEN).ok_or_else(|| bad("root record is truncated"))?;
|
||||
let mut shapes = Vec::new();
|
||||
let mut points_total = 0usize;
|
||||
let mut cursor = root.start;
|
||||
// An object's style record comes before its outline: search from the last outline.
|
||||
let mut style_from = root.start;
|
||||
let budget = std::cell::Cell::new(MAX_SCANNED_NODES);
|
||||
while let Some((at, path)) = find_path(body, cursor, root.end, &budget) {
|
||||
cursor = path.end;
|
||||
if shapes.len() >= MAX_SHAPES {
|
||||
return Err(bad("more than 100,000 shapes"));
|
||||
}
|
||||
let nodes: usize = path.contours.iter().map(|c| c.nodes.len()).sum();
|
||||
points_total = points_total.saturating_add(nodes);
|
||||
if points_total > MAX_POINTS {
|
||||
return Err(bad("more than 2,000,000 points"));
|
||||
}
|
||||
let bez = to_bez(&path);
|
||||
let limit = f64::from(MAX_COORD_MM);
|
||||
let wild = |p: Point| !p.is_finite() || p.x.abs() > limit || p.y.abs() > limit;
|
||||
if bez.elements().iter().any(|el| el.end_point().is_some_and(wild)) {
|
||||
warnings.push(format!("skipped a path at byte {at}: coordinates out of range"));
|
||||
continue;
|
||||
}
|
||||
shapes.push(Shape { path: bez, line_color: line_color_before(body, style_from, at) });
|
||||
style_from = path.end;
|
||||
}
|
||||
if budget.get() == 0 {
|
||||
return Err(bad("too much data that only looks like outlines"));
|
||||
}
|
||||
Ok(Imported { version, page_mm: page_size(body, &root), shapes, warnings })
|
||||
}
|
||||
|
||||
/// `.studio3`: the header is a table of little-endian u64 section lengths; the
|
||||
/// sections follow the header back to back and section 0 is the document body.
|
||||
fn v3_body(bytes: &[u8]) -> Result<&[u8], Error> {
|
||||
let bad = |what: &str| Error::Malformed(what.to_string());
|
||||
if bytes.len() < V3_HEADER_LEN {
|
||||
return Err(bad("header is truncated"));
|
||||
}
|
||||
let mut total: u64 = 0;
|
||||
let mut first: Option<u64> = None;
|
||||
let mut at = V3_TABLE_START;
|
||||
while at < V3_TABLE_END {
|
||||
let len = u64_at(bytes, at).ok_or_else(|| bad("section table is truncated"))?;
|
||||
first.get_or_insert(len);
|
||||
total = total.checked_add(len).ok_or_else(|| bad("section table overflows"))?;
|
||||
at += 8;
|
||||
}
|
||||
let available = (bytes.len() - V3_HEADER_LEN) as u64;
|
||||
if total > available {
|
||||
return Err(bad("sections run past the end of the file"));
|
||||
}
|
||||
let first = usize::try_from(first.unwrap_or(0)).map_err(|_| bad("section 0 is too large"))?;
|
||||
let body = bytes.get(V3_HEADER_LEN..V3_HEADER_LEN.saturating_add(first)).ok_or_else(|| bad("section 0 is truncated"))?;
|
||||
if body.get(..MAGIC_LEN) != Some(b"silhouette05;".as_slice()) || body.get(MAGIC_LEN) != Some(&1) {
|
||||
// The table parsed but the body is not the plain record layout we know:
|
||||
// the shape of an encrypted or protected design. Never try to read it.
|
||||
return Err(Error::Protected);
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// A record: marker byte `0x01`, then a u32 length that counts itself, then payload.
|
||||
/// `start..end` is the payload.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Block {
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
fn block(d: &[u8], at: usize) -> Option<Block> {
|
||||
if *d.get(at)? != 1 {
|
||||
return None;
|
||||
}
|
||||
let len = usize::try_from(u32_at(d, at + 1)?).ok()?;
|
||||
if len < 4 {
|
||||
return None;
|
||||
}
|
||||
let end = (at + 1).checked_add(len)?;
|
||||
(end <= d.len()).then_some(Block { start: at + 5, end })
|
||||
}
|
||||
|
||||
/// An object's outline in page coordinates, as every drawable object (shape, line,
|
||||
/// compound path, text) stores it after its drawing-frame nodes and matrix:
|
||||
///
|
||||
/// ```text
|
||||
/// 01 L u32 contours-1 contours × contour
|
||||
/// contour: u32 Lc u8 closed u32 nodes-1 nodes × node (Lc counts itself)
|
||||
/// node: f32 x f32 y in-handle out-handle
|
||||
/// handle: 00 (none) | 01 f32 dx f32 dy (relative to the node)
|
||||
/// ```
|
||||
/// Every length and count must agree before a match is accepted.
|
||||
struct PathRecord {
|
||||
contours: Vec<Contour>,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
struct Contour {
|
||||
closed: bool,
|
||||
nodes: Vec<Node>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Node {
|
||||
at: Point,
|
||||
handle_in: Option<Vec2>,
|
||||
handle_out: Option<Vec2>,
|
||||
}
|
||||
|
||||
fn find_path(d: &[u8], from: usize, to: usize, budget: &std::cell::Cell<usize>) -> Option<(usize, PathRecord)> {
|
||||
let window = d.get(from..to)?;
|
||||
let mut offset = 0;
|
||||
while let Some(rel) = window.get(offset..)?.iter().position(|&b| b == 1) {
|
||||
let at = from + offset + rel;
|
||||
if let Some(path) = path_at(d, at, to, budget) {
|
||||
return Some((at, path));
|
||||
}
|
||||
offset += rel + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn path_at(d: &[u8], at: usize, limit: usize, budget: &std::cell::Cell<usize>) -> Option<PathRecord> {
|
||||
let outer = block(d, at)?;
|
||||
if outer.end > limit {
|
||||
return None;
|
||||
}
|
||||
let count = usize::try_from(u32_at(d, outer.start)?).ok()?.checked_add(1)?;
|
||||
let mut contours = Vec::new();
|
||||
let mut p = outer.start + 4;
|
||||
// Points one outline may still hold: no record keeps more than `MAX_POINTS` nodes in memory.
|
||||
let mut room = MAX_POINTS;
|
||||
while p < outer.end {
|
||||
if contours.len() >= count {
|
||||
return None;
|
||||
}
|
||||
let (contour, end) = contour_at(d, p, outer.end, room, budget)?;
|
||||
room -= contour.nodes.len();
|
||||
contours.push(contour);
|
||||
p = end;
|
||||
}
|
||||
(p == outer.end && contours.len() == count).then_some(PathRecord { contours, end: outer.end })
|
||||
}
|
||||
|
||||
fn contour_at(d: &[u8], at: usize, limit: usize, room: usize, budget: &std::cell::Cell<usize>) -> Option<(Contour, usize)> {
|
||||
let end = at.checked_add(usize::try_from(u32_at(d, at)?).ok()?)?;
|
||||
let closed = match *d.get(at + 4)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return None,
|
||||
};
|
||||
let count = usize::try_from(u32_at(d, at + 5)?).ok()?.checked_add(1)?;
|
||||
// Each node takes at least 10 bytes: refuse counts the bytes cannot hold.
|
||||
if end > limit || count > (end - at) / 10 {
|
||||
return None;
|
||||
}
|
||||
// Checked before allocating. Zero marks the budget spent: every later candidate fails.
|
||||
let left = if count > room { None } else { budget.get().checked_sub(count) };
|
||||
budget.set(left.unwrap_or(0));
|
||||
left?;
|
||||
let mut nodes = Vec::with_capacity(count);
|
||||
let mut p = at + 9;
|
||||
for _ in 0..count {
|
||||
let point = point_at(d, p)?;
|
||||
p += 8;
|
||||
let mut handle = || -> Option<Option<Vec2>> {
|
||||
let flag = *d.get(p)?;
|
||||
p += 1;
|
||||
match flag {
|
||||
0 => Some(None),
|
||||
1 => {
|
||||
let v = point_at(d, p)?.to_vec2();
|
||||
p += 8;
|
||||
Some(Some(v))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let handle_in = handle()?;
|
||||
let handle_out = handle()?;
|
||||
nodes.push(Node { at: point, handle_in, handle_out });
|
||||
}
|
||||
(p == end).then_some((Contour { closed, nodes }, end))
|
||||
}
|
||||
|
||||
fn point_at(d: &[u8], at: usize) -> Option<Point> {
|
||||
let (x, y) = (f32_at(d, at)?, f32_at(d, at + 4)?);
|
||||
let ok = |v: f32| v.is_finite() && v.abs() <= MAX_COORD_MM;
|
||||
(ok(x) && ok(y)).then(|| Point::new(f64::from(x), f64::from(y)))
|
||||
}
|
||||
|
||||
/// One subpath per contour. A segment is cubic where either end has a handle,
|
||||
/// straight otherwise; a closed contour also joins its last node to its first.
|
||||
fn to_bez(path: &PathRecord) -> BezPath {
|
||||
let mut bez = BezPath::new();
|
||||
for contour in &path.contours {
|
||||
let Some(first) = contour.nodes.first() else {
|
||||
continue;
|
||||
};
|
||||
bez.move_to(first.at);
|
||||
let closing = contour.closed.then_some(first);
|
||||
let next = contour.nodes.iter().skip(1).chain(closing);
|
||||
let last = contour.nodes.len() - 1;
|
||||
for (i, (from, to)) in contour.nodes.iter().zip(next).enumerate() {
|
||||
match (from.handle_out, to.handle_in) {
|
||||
// A straight closing edge is the close itself.
|
||||
(None, None) if contour.closed && i == last => {}
|
||||
(None, None) => bez.line_to(to.at),
|
||||
(out, inn) => bez.curve_to(from.at + out.unwrap_or_default(), to.at + inn.unwrap_or_default(), to.at),
|
||||
}
|
||||
}
|
||||
if contour.closed {
|
||||
bez.close_path();
|
||||
}
|
||||
}
|
||||
bez
|
||||
}
|
||||
|
||||
/// The line colour of the last object style in `from..to`. A style holds a cut-action
|
||||
/// record (`01 L`, then a name string starting "cut": "cut", "cutunion", …); 25 bytes
|
||||
/// before it sit a flag byte `01` and the line colour as four f32 in B, G, R, A order.
|
||||
fn line_color_before(d: &[u8], from: usize, to: usize) -> Option<[u8; 3]> {
|
||||
let window = d.get(from..to)?;
|
||||
let mut found = None;
|
||||
let mut offset = 0;
|
||||
while let Some(rel) = window.get(offset..)?.windows(3).position(|w| w == b"cut") {
|
||||
let name = from + offset + rel;
|
||||
offset += rel + 1;
|
||||
let Some(record) = name.checked_sub(9) else {
|
||||
continue;
|
||||
};
|
||||
let name_len = u32_at(d, name - 4).and_then(|n| usize::try_from(n).ok());
|
||||
let plausible =
|
||||
d.get(record) == Some(&1) && name_len.is_some_and(|n| (3..=32).contains(&n)) && record >= from + 25 && d.get(record - 25) == Some(&1);
|
||||
if !plausible {
|
||||
continue;
|
||||
}
|
||||
let channel = |i: usize| {
|
||||
let v = f32_at(d, record - 24 + 4 * i)?;
|
||||
(v.is_finite() && (0.0..=1.0).contains(&v)).then(|| (v * 255.0).round() as u8)
|
||||
};
|
||||
if let (Some(b), Some(g), Some(r)) = (channel(0), channel(1), channel(2)) {
|
||||
found = Some([r, g, b]);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Page width and height (mm): the root's second record, 0x29 bytes into its payload.
|
||||
/// Seen in one page size only (12 × 12 in), so it is checked hard and optional.
|
||||
fn page_size(d: &[u8], root: &Block) -> Option<(f64, f64)> {
|
||||
let first = block(d, root.start)?;
|
||||
let second = block(d, first.end)?;
|
||||
let at = second.start + 0x29;
|
||||
if at + 8 > second.end {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (f32_at(d, at)?, f32_at(d, at + 4)?);
|
||||
let sane = |v: f32| v.is_finite() && (1.0..=10_000.0).contains(&v);
|
||||
// Stored as f32: round to a micrometre so 304.8 stays 304.8.
|
||||
let um = |v: f32| (f64::from(v) * 1000.0).round() / 1000.0;
|
||||
(sane(w) && sane(h)).then_some((um(w), um(h)))
|
||||
}
|
||||
|
||||
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u64_at(d: &[u8], at: usize) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(d.get(at..at.checked_add(8)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn f32_at(d: &[u8], at: usize) -> Option<f32> {
|
||||
u32_at(d, at).map(f32::from_bits)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn block(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![1];
|
||||
out.extend_from_slice(&(payload.len() as u32 + 4).to_le_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
/// One object outline in the page-coordinate (compact) layout the reader reads.
|
||||
fn path_record(contours: &[(bool, &[(f32, f32)])]) -> Vec<u8> {
|
||||
let mut outer = (contours.len() as u32 - 1).to_le_bytes().to_vec();
|
||||
for &(closed, points) in contours {
|
||||
let mut c = vec![u8::from(closed)];
|
||||
c.extend_from_slice(&(points.len() as u32 - 1).to_le_bytes());
|
||||
for &(x, y) in points {
|
||||
c.extend_from_slice(&x.to_le_bytes());
|
||||
c.extend_from_slice(&y.to_le_bytes());
|
||||
c.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
outer.extend_from_slice(&(c.len() as u32 + 4).to_le_bytes());
|
||||
outer.extend(c);
|
||||
}
|
||||
block(&outer)
|
||||
}
|
||||
|
||||
/// A v2 body: header, root record holding a filler record, the page record and paths.
|
||||
fn v2(page: (f32, f32), paths: &[&[(f32, f32)]]) -> Vec<u8> {
|
||||
let records: Vec<_> = paths.iter().map(|p| path_record(&[(true, p)])).collect();
|
||||
v2_records(page, &records)
|
||||
}
|
||||
|
||||
fn v2_records(page: (f32, f32), records: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut page_payload = vec![0u8; 0x29];
|
||||
page_payload.extend_from_slice(&page.0.to_le_bytes());
|
||||
page_payload.extend_from_slice(&page.1.to_le_bytes());
|
||||
let mut root = block(&[0u8; 8]);
|
||||
root.extend(block(&page_payload));
|
||||
for r in records {
|
||||
root.extend_from_slice(r);
|
||||
}
|
||||
let mut out = b"silhouette04;".to_vec();
|
||||
out.extend(block(&root));
|
||||
out
|
||||
}
|
||||
|
||||
fn v3(body_v2: &[u8]) -> Vec<u8> {
|
||||
let mut body = body_v2.to_vec();
|
||||
body[..13].copy_from_slice(b"silhouette05;");
|
||||
let thumb = b"not-a-real-thumbnail";
|
||||
let mut out = b"silhouette05;".to_vec();
|
||||
out.resize(1024, 0);
|
||||
out[0x10..0x18].copy_from_slice(&(body.len() as u64).to_le_bytes());
|
||||
out[0x18..0x20].copy_from_slice(&(thumb.len() as u64).to_le_bytes());
|
||||
out.extend(body);
|
||||
out.extend_from_slice(thumb);
|
||||
out
|
||||
}
|
||||
|
||||
const RECT: &[(f32, f32)] = &[(62.38, 58.21), (62.38, 169.35), (201.3, 169.35), (201.3, 58.21)];
|
||||
|
||||
/// An object style as Silhouette writes it: flag, line colour as B, G, R, A, 8 bytes,
|
||||
/// then the cut-action record (`01 L`, name "cut").
|
||||
fn style(rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut out = vec![1];
|
||||
for c in [rgb[2], rgb[1], rgb[0], 255] {
|
||||
out.extend_from_slice(&(f32::from(c) / 255.0).to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&[0; 8]);
|
||||
let mut action = 3u32.to_le_bytes().to_vec();
|
||||
action.extend_from_slice(b"cut");
|
||||
out.extend(block(&action));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_rectangle_decodes_in_page_millimetres_y_down() {
|
||||
let got = import(&v2((304.8, 304.8), &[RECT])).unwrap();
|
||||
assert_eq!(got.version, Version::V2);
|
||||
assert_eq!(got.page_mm, Some((304.8, 304.8)));
|
||||
let r = kurbo::Shape::bounding_box(&got.shapes[0].path);
|
||||
assert!((r.x0 - 62.38).abs() < 1e-3 && (r.y0 - 58.21).abs() < 1e-3, "{r:?}");
|
||||
assert!((r.x1 - 201.3).abs() < 1e-3 && (r.y1 - 169.35).abs() < 1e-3, "{r:?}");
|
||||
assert_eq!(got.shapes[0].line_color, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_container_reads_section_zero() {
|
||||
let got = import(&v3(&v2((215.9, 279.4), &[RECT, &[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0)]]))).unwrap();
|
||||
assert_eq!((got.version, got.shapes.len(), got.page_mm), (Version::V3, 2, Some((215.9, 279.4))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_stay_open_and_compound_contours_stay_one_shape() {
|
||||
let line = path_record(&[(false, &[(25.4, 25.4), (76.2, 50.8)])]);
|
||||
let compound = path_record(&[(true, RECT), (true, &[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0)])]);
|
||||
let got = import(&v2_records((304.8, 304.8), &[line, compound])).unwrap();
|
||||
let closes = |i: usize| got.shapes[i].path.elements().iter().filter(|e| matches!(e, kurbo::PathEl::ClosePath)).count();
|
||||
assert_eq!(got.shapes.len(), 2);
|
||||
assert_eq!(closes(0), 0, "a line is open");
|
||||
assert_eq!(closes(1), 2, "both contours of the compound path close");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_outline_takes_the_line_colour_of_the_style_before_it() {
|
||||
let ochre = [0xb8, 0x7c, 0x1b];
|
||||
let blue = [0, 0, 0xff];
|
||||
let mut records = Vec::new();
|
||||
for rgb in [ochre, blue] {
|
||||
let mut object = style(rgb);
|
||||
object.extend(path_record(&[(true, RECT)]));
|
||||
records.push(object);
|
||||
}
|
||||
records.push(path_record(&[(true, RECT)]));
|
||||
let got = import(&v2_records((304.8, 304.8), &records)).unwrap();
|
||||
let colours: Vec<_> = got.shapes.iter().map(|s| s.line_color).collect();
|
||||
assert_eq!(colours, [Some(ochre), Some(blue), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_foreign_and_unsupported_files_are_rejected() {
|
||||
let mut file = v3(&v2((304.8, 304.8), &[RECT]));
|
||||
for b in &mut file[1024..1024 + 40] {
|
||||
*b ^= 0x5a;
|
||||
}
|
||||
assert_eq!(import(&file).unwrap_err(), Error::Protected);
|
||||
assert_eq!(import(b"<svg/>").unwrap_err(), Error::NotStudio);
|
||||
assert_eq!(import(b"silhouette03;\x01\x04\0\0\0").unwrap_err(), Error::Unsupported { version: "03".to_string() });
|
||||
assert_eq!(sniff(b"silhouette05;"), Some(Version::V3));
|
||||
assert!(is_studio_path("A.STUDIO3") && !is_studio_path("a.svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_finite_coordinate_is_never_imported() {
|
||||
let got = import(&v2((304.8, 304.8), &[&[(f32::NAN, 0.0), (1.0, 1.0)], RECT])).unwrap();
|
||||
assert_eq!(got.shapes.len(), 1);
|
||||
}
|
||||
|
||||
/// Every 20 bytes starts a candidate outline of 18,432 nodes that parses to its end and
|
||||
/// then fails its contour count: without the scan budget this is billions of node reads.
|
||||
#[test]
|
||||
fn overlapping_near_outlines_are_refused_not_scanned_quadratically() {
|
||||
let n = 18_432u32;
|
||||
let mut period = vec![1];
|
||||
period.extend_from_slice(&(17 + 10 * n).to_le_bytes());
|
||||
period.extend_from_slice(&1u32.to_le_bytes());
|
||||
period.extend_from_slice(&(9 + 10 * n).to_le_bytes());
|
||||
period.push(0);
|
||||
period.extend_from_slice(&(n - 1).to_le_bytes());
|
||||
period.extend_from_slice(&[0, 0]);
|
||||
assert_eq!(period.len(), 20);
|
||||
let file = v2_records((304.8, 304.8), &[period.repeat(100_000)]);
|
||||
assert!(matches!(import(&file), Err(Error::Malformed(what)) if what.contains("looks like outlines")));
|
||||
}
|
||||
|
||||
/// One contour claiming more nodes than `MAX_POINTS`, with the bytes to back the claim.
|
||||
#[test]
|
||||
fn a_contour_past_the_point_limit_is_refused_before_its_nodes_are_read() {
|
||||
let n = MAX_POINTS as u32 + 1;
|
||||
let mut outer = 0u32.to_le_bytes().to_vec();
|
||||
outer.extend_from_slice(&(9 + 10 * n).to_le_bytes());
|
||||
outer.push(1);
|
||||
outer.extend_from_slice(&(n - 1).to_le_bytes());
|
||||
outer.resize(outer.len() + 10 * n as usize, 0);
|
||||
let file = v2_records((304.8, 304.8), &[block(&outer)]);
|
||||
assert!(matches!(import(&file), Err(Error::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncations_and_bit_flips_never_panic() {
|
||||
let mut state: u64 = 0x5111_0E77;
|
||||
for seed in [v2((304.8, 304.8), &[RECT]), v3(&v2((304.8, 304.8), &[RECT, RECT]))] {
|
||||
for cut in 0..seed.len() {
|
||||
let _ = import(&seed[..cut]);
|
||||
}
|
||||
for pos in 0..seed.len() {
|
||||
let mut mutant = seed.clone();
|
||||
mutant[pos] ^= 1 << (pos % 8);
|
||||
let _ = import(&mutant);
|
||||
}
|
||||
for _ in 0..4000 {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
let mut mutant = seed.clone();
|
||||
let pos = (state as usize) % mutant.len();
|
||||
mutant[pos] ^= 1 << (state >> 61);
|
||||
let _ = import(&mutant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user