mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
EPS: images encoded with CCITTFaxDecode draw
The PostScript interpreter that reads EPS and PostScript files had no CCITTFaxDecode filter. Reading a file whose image data went through it stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. CCITTFaxDecode now decodes CCITT Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data to rows of 1-bit samples. It reads K, Columns, Rows, EndOfBlock, EndOfLine, EncodedByteAlign and BlackIs1 (PLRM 3rd ed., §3.13; ISO 32000-2, §7.4.6). With EndOfBlock the data ends at its EOFB or RTC regardless of Rows, and the RTC of mixed Group 3 data, six EOLs each with a tag bit, is found by the filter. Damaged data keeps the rows decoded before it, and damage in the first row is an ioerror. Columns outside 1 to 65,535 and Rows below 0 are a rangecheck, and data that would decode to more than 256 MiB is refused. hayro-ccitt 0.3.0, already in Cargo.lock through hayro-syntax, becomes a dependency of vectorcraft-eps. The tests encode their images in the test, with the fax crate's Group 4 encoder and T.4 run codes (a dev-dependency, already in Cargo.lock through tiff), and fail without the change. The import fuzz tests read a Group 4 and a Group 3 image coded by hand from the T.4 and T.6 tables, read the same file with random damage, and add the filter and its parameters to the hostile programs. Docs: docs/file-format-parity.md, docs/target-app-parity.md.
This commit is contained in:
Generated
+2
@@ -4686,7 +4686,9 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
|
||||
@@ -79,6 +79,9 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"]
|
||||
rustix = { version = "1.1", default-features = false }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
|
||||
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
|
||||
hayro-ccitt = "0.3"
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
|
||||
@@ -1662,6 +1662,49 @@ proptest! {
|
||||
let text = mutate_text(&cmyk_images_eps(), cut, &edits);
|
||||
survive("mutated EPS CMYK images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// Damaged or hostile CCITT fax images (Group 4 and Group 3 data, their parameters): read as
|
||||
/// images, or as an error.
|
||||
#[test]
|
||||
fn eps_ccitt_images_never_panic(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '1', '9', 'f', '8', ' ', '\n', '<', '>', '/', '-', 't', 'e'])), 0..12)) {
|
||||
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
|
||||
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
/// ITU-T T.4 and T.6: two rows of white 2, black 4, white 2 over a black row. The first is Group 4
|
||||
/// (horizontal, vertical 0 and vertical ±2 modes, then EOFB), the second Group 3 one-dimensional
|
||||
/// with an EOL before each row and six at the end, drawn as an image mask.
|
||||
fn ccitt_images_eps() -> String {
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\ngsave 40 30 scale \
|
||||
8 3 1 [8 0 0 -3 0 3] currentfile /ASCIIHexDecode filter << /K -1 /Columns 8 /Rows 3 >> /CCITTFaxDecode filter image\n\
|
||||
2efc20c0040040>\ngrestore 50 50 translate 40 30 scale \
|
||||
8 3 false [8 0 0 -3 0 3] currentfile /ASCIIHexDecode filter << /Columns 8 /EndOfLine true /BlackIs1 false >> /CCITTFaxDecode filter imagemask\n\
|
||||
00176e002edc004d45001001001001001001>\nshowpage\n%%EOF\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// [`ccitt_images_eps`] as it is: both images draw their rows (`B` black, `w` white, `.` left
|
||||
/// clear by the mask), without a warning.
|
||||
#[test]
|
||||
fn the_ccitt_images_fixture_reads_both_images() {
|
||||
let r = vectorcraft_eps::import(ccitt_images_eps().as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let d = &r.document;
|
||||
let shade = |p: &image::Rgba<u8>| match p.0 {
|
||||
[_, _, _, 0] => '.',
|
||||
[r, ..] if r < 128 => 'B',
|
||||
_ => 'w',
|
||||
};
|
||||
let mut found = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
found.push(px.pixels().map(shade).collect::<String>());
|
||||
}
|
||||
});
|
||||
assert_eq!(found, ["wwBBBBwwwwBBBBwwBBBBBBBB", "..BBBB....BBBB..BBBBBBBB"]);
|
||||
}
|
||||
|
||||
/// An EPS with CMYK images that the PostScript interpreter reads: a CMYK JPEG through `DCTDecode`
|
||||
@@ -1726,6 +1769,7 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"systemdict", "/userdict", "SharedFontDirectory", "(1 2 add) 0 () /SubFileDecode filter cvx exec", "currentfile cvx exec",
|
||||
"/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "currentfile 0 () /SubFileDecode filter cvx exec", "cvlit", "{ (a) cshow }",
|
||||
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
|
||||
"/CCITTFaxDecode", "<< /K -1 /Columns 8 /Rows 3 >> /CCITTFaxDecode", "<< /K 2 /Columns 99999 /EndOfLine true /EncodedByteAlign true /BlackIs1 true >>",
|
||||
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
|
||||
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
|
||||
"<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 1] /Function << /FunctionType 2 /C0 [0 0 0] /C1 [1 1 1] /N 1 >> >>",
|
||||
|
||||
@@ -25,8 +25,12 @@ kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = { workspace = true }
|
||||
# Images read through `CCITTFaxDecode`.
|
||||
hayro-ccitt = { workspace = true }
|
||||
# Live effects' params, as the art styles that name them give them.
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# Tests: a Group 4 encoder and the T.4 run codes, to write CCITT test images.
|
||||
fax = "0.2"
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use hayro_ccitt::{DecodeSettings, Decoder, DecoderContext, EncodingMode};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::cmyk::{Inks, jpeg_components};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeId, NodeKind, OpacityMask};
|
||||
@@ -164,6 +165,118 @@ fn dct(raw: &[u8]) -> Res<(Vec<u8>, usize, u8)> {
|
||||
if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) }
|
||||
}
|
||||
|
||||
/// CCITT fax data (ITU-T T.4 Group 3, one- or two-dimensional, and T.6 Group 4) decoded to rows
|
||||
/// of 1-bit samples, each padded to a whole byte, and how many bytes of `raw` it took. With
|
||||
/// `EndOfBlock` the rows end at the end-of-block code (EOFB, or RTC in Group 3) regardless of
|
||||
/// `Rows`; without it they end after `Rows` rows, or at the end of the data when `Rows` is 0.
|
||||
/// Damaged data ends the rows: those decoded before it are kept, and damage in the first row is an
|
||||
/// `ioerror`. Rows that would take more than `MAX_DATA` bytes are refused.
|
||||
fn fax(raw: &[u8], settings: DecodeSettings) -> Res<(Vec<u8>, usize)> {
|
||||
let row = (settings.columns as usize).div_ceil(8).max(1);
|
||||
let most = u32::try_from(MAX_DATA / row).unwrap_or(u32::MAX);
|
||||
if !settings.end_of_block && settings.rows > most {
|
||||
return Err(PsError::Limit("decoded data is too large"));
|
||||
}
|
||||
// Without a row count that ends the data, a row past `most` shows that the data is too large.
|
||||
let rows = if settings.end_of_block || settings.rows == 0 { most.saturating_add(1) } else { settings.rows };
|
||||
// The decoder reads the RTC of Group 3 two-dimensional data as rows: it gets the data before it.
|
||||
let rtc = match settings.encoding {
|
||||
EncodingMode::Group3_2D { .. } if settings.end_of_block => rtc(raw),
|
||||
_ => None,
|
||||
};
|
||||
let data = rtc.and_then(|(cut, _)| raw.get(..cut)).unwrap_or(raw);
|
||||
let mut out = FaxRows::default();
|
||||
let read = hayro_ccitt::decode(data, &mut out, &mut DecoderContext::new(DecodeSettings { rows, ..settings }));
|
||||
if out.rows > most {
|
||||
return Err(PsError::Limit("decoded data is too large"));
|
||||
}
|
||||
let used = match read {
|
||||
Ok(used) => used,
|
||||
Err(_) if out.rows > 0 => {
|
||||
out.data.truncate(out.rows as usize * row);
|
||||
// The whole rows decoded again, for how many bytes they took.
|
||||
let used = hayro_ccitt::decode(data, &mut Dropped, &mut DecoderContext::new(DecodeSettings { rows: out.rows, ..settings }));
|
||||
used.unwrap_or(data.len())
|
||||
}
|
||||
Err(_) => return io("CCITTFaxDecode"),
|
||||
};
|
||||
Ok((out.data, rtc.map_or(used, |(_, end)| end)))
|
||||
}
|
||||
|
||||
/// The RTC that ends Group 3 two-dimensional data (six EOLs, each followed by a 1 tag bit, ITU-T
|
||||
/// T.4 §4.2.4): the byte that holds the 1 bit of its first EOL, and the byte after its last tag
|
||||
/// bit. The first EOL with a 1 tag bit that another EOL follows starts it.
|
||||
fn rtc(raw: &[u8]) -> Option<(usize, usize)> {
|
||||
let bits = raw.len().saturating_mul(8);
|
||||
let bit = |i: usize| raw.get(i / 8).is_some_and(|b| b & (0x80 >> (i % 8)) != 0);
|
||||
let zeros = |i: usize| (i..bits).take_while(|j| !bit(*j)).count();
|
||||
// An EOL (its fill bits first) and a 1 tag bit from `i`: the EOL's 1 bit and where they end.
|
||||
let eol = |i: usize| {
|
||||
let one = i + zeros(i);
|
||||
(one - i >= 11 && bit(one) && bit(one + 1)).then_some((one, one + 2))
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < bits {
|
||||
if let Some((one, next)) = eol(i)
|
||||
&& let Some((_, mut end)) = eol(next)
|
||||
{
|
||||
for _ in 2..6 {
|
||||
match eol(end) {
|
||||
Some((_, e)) => end = e,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
return Some((one / 8, end.div_ceil(8)));
|
||||
}
|
||||
i += zeros(i) + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Rows of 1-bit samples from a CCITT decoder, each padded to a whole byte: 1 is white, or black
|
||||
/// with `BlackIs1`.
|
||||
#[derive(Default)]
|
||||
struct FaxRows {
|
||||
data: Vec<u8>,
|
||||
/// Samples in the row being decoded.
|
||||
x: u32,
|
||||
/// Whole rows decoded.
|
||||
rows: u32,
|
||||
}
|
||||
|
||||
impl Decoder for FaxRows {
|
||||
fn push_pixel(&mut self, white: bool) {
|
||||
if self.x.is_multiple_of(8) {
|
||||
self.data.push(0);
|
||||
}
|
||||
if white && let Some(b) = self.data.last_mut() {
|
||||
*b |= 0x80 >> (self.x % 8);
|
||||
}
|
||||
self.x = self.x.saturating_add(1);
|
||||
}
|
||||
|
||||
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
|
||||
self.data.extend(std::iter::repeat_n(if white { 0xff } else { 0 }, chunk_count as usize));
|
||||
self.x = self.x.saturating_add(chunk_count.saturating_mul(8));
|
||||
}
|
||||
|
||||
fn next_line(&mut self) {
|
||||
self.x = 0;
|
||||
self.rows = self.rows.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A CCITT decoder that drops what it decodes.
|
||||
struct Dropped;
|
||||
|
||||
impl Decoder for Dropped {
|
||||
fn push_pixel(&mut self, _: bool) {}
|
||||
|
||||
fn push_pixel_chunk(&mut self, _: bool, _: u32) {}
|
||||
|
||||
fn next_line(&mut self) {}
|
||||
}
|
||||
|
||||
/// A decoding filter and its parameters.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Filter {
|
||||
@@ -172,6 +285,8 @@ pub(crate) struct Filter {
|
||||
sub: (usize, Vec<u8>),
|
||||
/// `FlateDecode`'s and `LZWDecode`'s `Predictor`: what it undoes.
|
||||
predictor: Option<Predictor>,
|
||||
/// `CCITTFaxDecode`'s parameters.
|
||||
fax: Option<DecodeSettings>,
|
||||
}
|
||||
|
||||
/// A `Predictor` (2: TIFF, 10–15: PNG) with its `Colors`, `BitsPerComponent` and `Columns`.
|
||||
@@ -266,6 +381,39 @@ fn paeth(a: u8, b: u8, c: u8) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CCITTFaxDecode`'s parameters (PLRM 3rd ed., §3.13, `CCITTFaxDecode` parameters; ISO 32000-2,
|
||||
/// §7.4.6): `K` (below 0 Group 4, 0 Group 3 one-dimensional, above 0 Group 3 mixed), `Columns`,
|
||||
/// `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`, with their defaults where
|
||||
/// `d` leaves them out. `Columns` outside 1 to 65,535 and `Rows` below 0 are a `rangecheck`.
|
||||
fn fax_settings(d: Option<&DictRef>) -> Res<DecodeSettings> {
|
||||
let get = |k: &str| d.and_then(|d| d.borrow().get(&Key::name(k)).cloned());
|
||||
let num = |k: &str, default: f64| get(k).and_then(|o| o.as_num()).unwrap_or(default);
|
||||
let flag = |k: &str, default: bool| match get(k) {
|
||||
Some(Obj::Bool(b)) => b,
|
||||
_ => default,
|
||||
};
|
||||
let (k, columns, rows) = (num("K", 0.0), num("Columns", 1728.0), num("Rows", 0.0));
|
||||
if !k.is_finite() || !(1.0..=65_535.0).contains(&columns) || !(0.0..=f64::from(u32::MAX)).contains(&rows) {
|
||||
return ps_err("rangecheck", "CCITTFaxDecode");
|
||||
}
|
||||
let encoding = if k < 0.0 {
|
||||
EncodingMode::Group4
|
||||
} else if k == 0.0 {
|
||||
EncodingMode::Group3_1D
|
||||
} else {
|
||||
EncodingMode::Group3_2D { k: k.min(f64::from(u32::MAX)) as u32 }
|
||||
};
|
||||
Ok(DecodeSettings {
|
||||
columns: columns as u32,
|
||||
rows: rows as u32,
|
||||
end_of_block: flag("EndOfBlock", true),
|
||||
end_of_line: flag("EndOfLine", false),
|
||||
rows_are_byte_aligned: flag("EncodedByteAlign", false),
|
||||
encoding,
|
||||
invert_black: flag("BlackIs1", false),
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes `f` decodes from `raw`, how many it read, and the channels of a decoded JPEG.
|
||||
fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec<u8>, usize, Option<u8>)> {
|
||||
let (data, used) = match &*f.name {
|
||||
@@ -292,6 +440,10 @@ fn decode(f: &Filter, raw: &[u8]) -> Res<(Vec<u8>, usize, Option<u8>)> {
|
||||
let (d, used, channels) = dct(raw)?;
|
||||
return Ok((d, used, Some(channels)));
|
||||
}
|
||||
"CCITTFaxDecode" => match f.fax {
|
||||
Some(settings) => fax(raw, settings)?,
|
||||
None => return io("CCITTFaxDecode"),
|
||||
},
|
||||
"SubFileDecode" => {
|
||||
let (count, eod) = &f.sub;
|
||||
if eod.is_empty() {
|
||||
@@ -540,13 +692,15 @@ impl Interp<'_> {
|
||||
} else {
|
||||
(0, vec![])
|
||||
};
|
||||
let mut predictor = None;
|
||||
let (mut predictor, mut params) = (None, None);
|
||||
if let Some(Obj::Dict(d)) = self.stack.last().cloned() {
|
||||
self.pop()?;
|
||||
predictor = Predictor::of(&d)?;
|
||||
params = Some(d);
|
||||
}
|
||||
let fax = if &*name == "CCITTFaxDecode" { Some(fax_settings(params.as_ref())?) } else { None };
|
||||
let spec_name = name.clone();
|
||||
let spec = Filter { name, sub, predictor };
|
||||
let spec = Filter { name, sub, predictor, fax };
|
||||
let stream = match self.pop()? {
|
||||
Obj::File { stream: f, .. } => {
|
||||
if let Stream::Buf { data, pos, .. } = &*f.borrow() {
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
//! read in turn (§4.10 "Images", `image` and `colorimage` in chapter 8), rows padded to a whole
|
||||
//! byte at every bit depth and odd widths (§4.10), and Flate predictors undone row by row (§3.13,
|
||||
//! `FlateDecode` parameters) (#498). CMYK images keep their ink amounts, as the Decode array
|
||||
//! reads their samples, and their transparent pixels become an opacity mask.
|
||||
//! reads their samples, and their transparent pixels become an opacity mask. CCITT fax data
|
||||
//! (`CCITTFaxDecode`: ITU-T T.4 Group 3 and T.6 Group 4) decodes to rows of 1-bit samples.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fax::{BitWriter, Bits};
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{ImageBlob, Node, NodeKind};
|
||||
|
||||
use super::tests::{all, read};
|
||||
use super::import;
|
||||
use super::tests::{all, eps, read};
|
||||
use super::tests_generators::pixel;
|
||||
|
||||
/// Each pixel of the first image, row by row.
|
||||
@@ -152,6 +157,182 @@ fn cmyk_jpegs_keep_their_ink_amounts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits a fax encoder writes, in order.
|
||||
#[derive(Clone, Default)]
|
||||
struct Written(Rc<RefCell<Vec<Bits>>>);
|
||||
|
||||
impl BitWriter for Written {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn write(&mut self, bits: Bits) -> Result<(), Self::Error> {
|
||||
self.0.borrow_mut().push(bits);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
|
||||
/// code), then runs of several lengths.
|
||||
fn picture() -> Vec<Vec<bool>> {
|
||||
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
|
||||
}
|
||||
|
||||
/// `row` as one-dimensional runs (modified Huffman), white and black in turn from a white one
|
||||
/// (ITU-T T.4 §4.1.1).
|
||||
fn runs(row: &[bool]) -> Vec<Bits> {
|
||||
let mut out = vec![];
|
||||
let (mut x, mut black) = (0, false);
|
||||
while x < row.len() {
|
||||
let n = row[x..].iter().take_while(|b| **b == black).count();
|
||||
let code = |n: usize| if black { fax::maps::black::encode(n as u16) } else { fax::maps::white::encode(n as u16) }.unwrap();
|
||||
if n >= 64 {
|
||||
out.push(code(n & !63));
|
||||
}
|
||||
out.push(code(n % 64));
|
||||
(x, black) = (x + n, !black);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `rows` as CCITT data. With `k` below 0 it is Group 4 (each row coded against the one above,
|
||||
/// ITU-T T.6), ending with EOFB. Otherwise it is Group 3 (T.4): one-dimensional rows, and with `k`
|
||||
/// above 0 every `k`th row from the first one-dimensional and the others coded against the row
|
||||
/// above, each after its tag bit. Group 3 rows follow an EOL when `eol`, and six end the data.
|
||||
/// With `aligned`, each row (its EOL first) and the six EOLs at the end start at a byte.
|
||||
fn fax_data(rows: &[Vec<bool>], k: i32, eol: bool, aligned: bool) -> Vec<u8> {
|
||||
let written = Written::default();
|
||||
let mut encoder = fax::encoder::Encoder::new(written.clone());
|
||||
let mut bits: Vec<Bits> = vec![];
|
||||
let align = |bits: &mut Vec<Bits>| {
|
||||
let used: usize = bits.iter().map(|b| usize::from(b.len)).sum();
|
||||
if aligned && !used.is_multiple_of(8) {
|
||||
bits.push(Bits { data: 0, len: (8 - used % 8) as u8 });
|
||||
}
|
||||
};
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let at = written.0.borrow().len();
|
||||
encoder.encode_line(row.iter().map(|b| if *b { fax::Color::Black } else { fax::Color::White }), row.len() as u16).unwrap();
|
||||
let two_d = written.0.borrow()[at..].to_vec();
|
||||
align(&mut bits);
|
||||
if k < 0 {
|
||||
bits.extend(two_d);
|
||||
continue;
|
||||
}
|
||||
if eol {
|
||||
bits.push(fax::maps::EOL);
|
||||
}
|
||||
let one_d = k == 0 || i % k as usize == 0;
|
||||
if k > 0 {
|
||||
bits.push(Bits { data: u16::from(one_d), len: 1 });
|
||||
}
|
||||
bits.extend(if one_d { runs(row) } else { two_d });
|
||||
}
|
||||
if k < 0 {
|
||||
bits.extend([fax::maps::EOL, fax::maps::EOL]);
|
||||
} else if eol {
|
||||
align(&mut bits);
|
||||
let end: &[Bits] = if k > 0 { &[fax::maps::EOL, Bits { data: 1, len: 1 }] } else { &[fax::maps::EOL] };
|
||||
bits.extend(end.repeat(6));
|
||||
}
|
||||
let mut w = fax::VecWriter::new();
|
||||
for b in bits {
|
||||
w.write(b).unwrap();
|
||||
}
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// Bytes as hexadecimal digits.
|
||||
fn hex(data: &[u8]) -> String {
|
||||
data.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Images read through `CCITTFaxDecode` draw: Group 4, Group 3 one-dimensional and Group 3 mixed
|
||||
/// one- and two-dimensional (ITU-T T.4 and T.6), with and without EOLs, with rows starting at a
|
||||
/// byte, ended by a row count (`EndOfBlock false`) or by the end-of-block code, which overrides
|
||||
/// `Rows`. `BlackIs1` gives black as 1 bits, so the image comes out inverted unless a Decode array
|
||||
/// of `[1 0]` reads it.
|
||||
#[test]
|
||||
fn ccitt_fax_images_draw() {
|
||||
let rows = picture();
|
||||
let want: Vec<u8> = rows.concat().iter().map(|black| if *black { 0 } else { 255 }).collect();
|
||||
let cases = [
|
||||
(-1, false, false, "/K -1 /Columns 70"),
|
||||
(-1, false, false, "/K -1 /Columns 70 /Rows 4"),
|
||||
(-1, false, false, "/K -1 /Columns 70 /Rows 7 /EndOfBlock false"),
|
||||
(-1, false, true, "/K -1 /Columns 70 /EncodedByteAlign true"),
|
||||
(0, false, false, "/Columns 70 /Rows 7"),
|
||||
(0, true, false, "/Columns 70 /EndOfLine true"),
|
||||
(0, true, false, "/Columns 70 /Rows 4 /EndOfLine true"),
|
||||
(0, true, true, "/Columns 70 /Rows 7 /EndOfLine true /EncodedByteAlign true"),
|
||||
(2, true, false, "/K 2 /Columns 70 /Rows 7 /EndOfLine true"),
|
||||
(3, true, false, "/K 3 /Columns 70 /EndOfLine true"),
|
||||
];
|
||||
for (k, eol, aligned, params) in cases {
|
||||
let data = hex(&fax_data(&rows, k, eol, aligned));
|
||||
for (black_is_1, decode, inverted) in [("", "", false), (" /BlackIs1 true", "", true), (" /BlackIs1 true", " /Decode [1 0]", false)] {
|
||||
let r = read(&format!(
|
||||
"30 20 scale << /ImageType 1 /Width 70 /Height 7 /BitsPerComponent 1{decode} /ImageMatrix [70 0 0 -7 0 7] \
|
||||
/DataSource currentfile /ASCIIHexDecode filter << {params}{black_is_1} >> /CCITTFaxDecode filter >> image\n{data}>"
|
||||
));
|
||||
let got: Vec<u8> = pixels(&r, 70, 7).iter().map(|p| if inverted { 255 - p[0] } else { p[0] }).collect();
|
||||
assert_eq!(got, want, "{params}{black_is_1}{decode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary CCITT data right after the operator that reads it: an image mask paints its black
|
||||
/// pixels, and the program goes on after the data's end-of-block code (EOFB in Group 4, RTC in
|
||||
/// Group 3, its EOLs followed by tag bits in two-dimensional data).
|
||||
#[test]
|
||||
fn ccitt_fax_data_in_the_program_ends_at_its_end_of_block() {
|
||||
let rows = picture();
|
||||
for (k, eol, aligned, params) in [
|
||||
(-1, false, false, "/K -1 /Columns 70"),
|
||||
(0, true, false, "/Columns 70 /EndOfLine true"),
|
||||
(2, true, false, "/K 2 /Columns 70 /Rows 7 /EndOfLine true"),
|
||||
(3, true, true, "/K 3 /Columns 70 /EndOfLine true /EncodedByteAlign true"),
|
||||
] {
|
||||
let mut file = eps("");
|
||||
file.truncate(file.len() - "\nshowpage\n%%EOF\n".len());
|
||||
file.extend_from_slice(
|
||||
format!("gsave 30 20 scale 70 7 false [70 0 0 -7 0 7] currentfile << {params} >> /CCITTFaxDecode filter imagemask\n").as_bytes(),
|
||||
);
|
||||
file.extend(fax_data(&rows, k, eol, aligned));
|
||||
file.extend_from_slice(b"\ngrestore 1 0 0 setrgbcolor 50 50 10 10 rectfill\nshowpage\n%%EOF\n");
|
||||
let r = import(&file).unwrap();
|
||||
let alpha: Vec<bool> = pixels(&r, 70, 7).iter().map(|p| p[3] == 255).collect();
|
||||
assert_eq!(alpha, rows.concat(), "{params}");
|
||||
assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "the rectangle after the data is drawn: {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Damaged CCITT data keeps the rows decoded before the damage, and damage in the first row
|
||||
/// stops the program. Parameters past the limits are refused.
|
||||
#[test]
|
||||
fn damaged_or_oversized_ccitt_fax_data() {
|
||||
let rows = picture();
|
||||
// Four rows, then an end-of-block code read as a fifth row: the four rows draw.
|
||||
let data = hex(&fax_data(&rows[..4], -1, false, false));
|
||||
let r = read(&format!(
|
||||
"30 20 scale 70 7 1 [70 0 0 -7 0 7] currentfile /ASCIIHexDecode filter << /K -1 /Columns 70 /Rows 7 /EndOfBlock false >> \
|
||||
/CCITTFaxDecode filter image\n{data}>"
|
||||
));
|
||||
let got: Vec<u8> = pixels(&r, 70, 7).iter().map(|p| p[0]).collect();
|
||||
let want: Vec<u8> = rows[..4].concat().iter().map(|black| if *black { 0 } else { 255 }).collect();
|
||||
assert_eq!(got[..4 * 70], want);
|
||||
for (params, why) in [
|
||||
("/K -1 /Columns 70 /Rows 7", "ioerror in `filter`: CCITTFaxDecode"),
|
||||
("/K -1 /Columns 65535 /Rows 100000 /EndOfBlock false", "decoded data is too large"),
|
||||
("/K -1 /Columns 65536", "rangecheck in `filter`: CCITTFaxDecode"),
|
||||
("/K -1 /Columns 0", "rangecheck in `filter`: CCITTFaxDecode"),
|
||||
("/K -1 /Columns 70 /Rows -1", "rangecheck in `filter`: CCITTFaxDecode"),
|
||||
] {
|
||||
let program = format!("0 0 10 10 rectfill 30 20 scale 70 7 1 [70 0 0 -7 0 7] <0000000000> << {params} >> /CCITTFaxDecode filter image");
|
||||
let r = import(&eps(&program)).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains(why)), "{params}: {:?}", r.warnings);
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows of an odd width at 1, 2, 4, 12 and 16 bits a sample start at a byte (their last byte
|
||||
/// padded), in one source and in several.
|
||||
#[test]
|
||||
|
||||
@@ -93,6 +93,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
|
||||
| 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 placement issues in some files (#722); 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) |
|
||||
| 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 | no | via Print to PostScript | |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; 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) |
|
||||
|
||||
@@ -630,6 +630,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 files are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`).
|
||||
- EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs.
|
||||
- EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open.
|
||||
- DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending.
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
|
||||
Reference in New Issue
Block a user