Files
vectorcraft/crates/eps/src/ps.rs
T
dotCooCoo 66f6be77ee .ai save: RGB and named colors keep their CMYK equivalents when a reopened file is saved
The .ai writers write RGB and Lab values to four decimals next to a CMYK
equivalent, but converted that equivalent from the color at full precision.
When a file was read back and saved again, its CMYK equivalents differed in
the fourth decimal (for example 0.6831 and 0.6832). In the editing data this
affected plain RGB colors, RGB gradient stops, global process and spot colors
and the header's custom colors; in the legacy .ai writer it affected RGB
gradient stops.

The CMYK equivalent of an RGB color, and of a global process or spot color,
is now converted from the values as they are written, so both saves convert
the same values. Lab values of global process and spot colors are now written
clamped to the range the reader reads (L 0 to 100, a and b -128 to 127).

A plain Lab fill or stroke and a Lab gradient stop are still written as RGB
with a CMYK equivalent converted from the Lab values. The reopened file holds
them as RGB, so their CMYK equivalents change once on its next save.

The art style round-trip test now compares the program exactly; its blanked
CMYK values and the four-decimal tolerance are no longer needed.
2026-11-12 08:35:39 -08:00

339 lines
11 KiB
Rust

//! PostScript writing primitives: numbers, strings, paths, the prolog's short operator names, and
//! the data encodings images and embedded data use (ASCII85, run-length, Flate).
use std::fmt::Write;
use std::io::{Read, Write as _};
use kurbo::PathEl;
use vectorcraft_geom::{Affine, BezPath, FillRule};
/// The procedures the page uses, in `VCdict`.
pub(crate) const PROLOG: &str = "/VCdict 64 dict def
VCdict begin
/bd {bind def} bind def
/m {moveto} bd /l {lineto} bd /c {curveto} bd /h {closepath} bd
/f {fill} bd /f* {eofill} bd /S {stroke} bd
/W {clip newpath} bd /W* {eoclip newpath} bd
/q {gsave} bd /Q {grestore} bd /cm {concat} bd
/g {setgray} bd /rg {setrgbcolor} bd /k {setcmykcolor} bd
/w {setlinewidth} bd /J {setlinecap} bd /j {setlinejoin} bd /M {setmiterlimit} bd /d {setdash} bd
/op {setoverprint} bd
end
";
/// Longest line of encoded data (DSC readers take up to 255 characters).
const LINE: usize = 76;
/// `v` as PostScript writes it: at most four decimals, no trailing zeros (0 when not finite).
pub(crate) fn num(v: f64) -> String {
let mut s = String::new();
push_num(&mut s, v);
s
}
/// Append [`num`]`(v)` to `s`.
pub(crate) fn push_num(s: &mut String, v: f64) {
push_num_places(s, v, 4);
}
/// The value [`num`]`(v)` stands for, as a reader parses it back.
pub(crate) fn written(v: f64) -> f64 {
num(v).parse().unwrap_or(0.0)
}
/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not
/// finite).
pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) {
let places = places.min(9);
let scale = 10f64.powi(places as i32);
// Past where the scaling overflows, values have no fractional digits to round.
let v = if !v.is_finite() {
0.0
} else if (v * scale).is_finite() {
(v * scale).round() / scale
} else {
v
};
if v == v.trunc() && v.abs() < 1e15 {
let _ = write!(s, "{}", v as i64);
return;
}
let start = s.len();
let _ = write!(s, "{v:.*}", places as usize);
let keep = s[start..].trim_end_matches('0').trim_end_matches('.').len();
s.truncate(start + keep);
}
/// `vs` as numbers separated by spaces, then a space.
pub(crate) fn push_nums(s: &mut String, vs: &[f64]) {
for v in vs {
push_num(s, *v);
s.push(' ');
}
}
/// `text` as a PostScript string: `(` `)` `\` escaped, anything outside printable ASCII as octal
/// escapes of its UTF-8 bytes.
pub(crate) fn string(text: &str) -> String {
let mut s = String::with_capacity(text.len() + 2);
s.push('(');
for b in text.bytes() {
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
s.push(char::from(b));
}
0x20..=0x7e => s.push(char::from(b)),
_ => {
let _ = write!(s, "\\{b:03o}");
}
}
}
s.push(')');
s
}
/// `bytes` as a PostScript string: `(` `)` `\` escaped, carriage returns, line feeds and tabs as
/// `\r` `\n` `\t`, other bytes outside printable ASCII as octal escapes.
pub(crate) fn bytes(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() + 2);
s.push('(');
for &b in bytes {
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
s.push(char::from(b));
}
b'\r' => s.push_str("\\r"),
b'\n' => s.push_str("\\n"),
b'\t' => s.push_str("\\t"),
0x20..=0x7e => s.push(char::from(b)),
_ => {
let _ = write!(s, "\\{b:03o}");
}
}
}
s.push(')');
s
}
/// A matrix operand `[a b c d e f]`.
pub(crate) fn matrix(a: Affine) -> String {
let mut s = String::from("[");
push_nums(&mut s, &a.as_coeffs());
s.pop();
s.push(']');
s
}
/// Append path `bp` (`m`, `l`, `c`, `h`; quadratics raised to cubics), one segment a line.
pub(crate) fn push_path(s: &mut String, bp: &BezPath) {
let mut last = kurbo::Point::ZERO;
let mut start = kurbo::Point::ZERO;
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => {
push_nums(s, &[p.x, p.y]);
s.push_str("m\n");
(last, start) = (p, p);
}
PathEl::LineTo(p) => {
push_nums(s, &[p.x, p.y]);
s.push_str("l\n");
last = p;
}
PathEl::QuadTo(a, p) => {
let c1 = last + (a - last) * (2.0 / 3.0);
let c2 = p + (a - p) * (2.0 / 3.0);
push_nums(s, &[c1.x, c1.y, c2.x, c2.y, p.x, p.y]);
s.push_str("c\n");
last = p;
}
PathEl::CurveTo(a, b, p) => {
push_nums(s, &[a.x, a.y, b.x, b.y, p.x, p.y]);
s.push_str("c\n");
last = p;
}
PathEl::ClosePath => {
s.push_str("h\n");
last = start;
}
}
}
}
/// The fill operator for `rule`.
pub(crate) fn fill_op(rule: FillRule) -> &'static str {
match rule {
FillRule::NonZero => "f\n",
FillRule::EvenOdd => "f*\n",
}
}
/// The clip operator for `rule` (it also ends the path).
pub(crate) fn clip_op(rule: FillRule) -> &'static str {
match rule {
FillRule::NonZero => "W\n",
FillRule::EvenOdd => "W*\n",
}
}
/// `bytes` in ASCII85 with its `~>` end, in lines of about [`LINE`] characters, none starting
/// with `%` (DSC readers would take it for a comment).
pub(crate) fn ascii85(bytes: &[u8]) -> String {
let mut raw = String::with_capacity(bytes.len() * 5 / 4 + 8);
let (chunks, rest) = bytes.as_chunks::<4>();
for c in chunks {
let v = u32::from_be_bytes(*c);
if v == 0 {
raw.push('z');
} else {
push85(&mut raw, v, 5);
}
}
if !rest.is_empty() {
let mut c = [0u8; 4];
for (d, s) in c.iter_mut().zip(rest) {
*d = *s;
}
push85(&mut raw, u32::from_be_bytes(c), rest.len() + 1);
}
raw.push_str("~>");
let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2);
if raw.starts_with('%') {
// Whitespace is ignored by readers, and keeps the first line from starting with `%`.
out.push(' ');
}
let mut col = 0;
for ch in raw.chars() {
if col >= LINE && ch != '%' {
out.push('\n');
col = 0;
}
out.push(ch);
col += 1;
}
out.push('\n');
out
}
/// The first `n` base-85 digits of `v`.
fn push85(s: &mut String, mut v: u32, n: usize) {
let mut d = [0u8; 5];
for slot in d.iter_mut().rev() {
*slot = (v % 85) as u8 + b'!';
v /= 85;
}
for b in d.iter().take(n) {
s.push(char::from(*b));
}
}
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
pub(crate) const MAX_ASCII85: usize = 1 << 28;
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed or longer
/// than [`MAX_ASCII85`].
pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
ascii85_decode_within(text, MAX_ASCII85)
}
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
/// text doesn't bound what it becomes (#1715).
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
let mut group = [0u8; 5];
let mut n = 0;
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
while let Some(b) = chars.next() {
if out.len() > max {
return None;
}
match b {
b'~' => {
if chars.next() != Some(b'>') {
return None;
}
if n == 1 {
return None;
}
if n > 1 {
let padded: Vec<u8> = group.iter().take(n).copied().chain(std::iter::repeat(b'u')).take(5).collect();
let v = value85(&padded)?;
out.extend(v.to_be_bytes().iter().take(n - 1));
}
return (out.len() <= max).then_some(out);
}
b'z' if n == 0 => out.extend([0; 4]),
b'!'..=b'u' => {
*group.get_mut(n)? = b;
n += 1;
if n == 5 {
out.extend(value85(&group)?.to_be_bytes());
n = 0;
}
}
_ => return None,
}
}
None
}
/// The value of five base-85 digits; `None` past `u32::MAX`.
fn value85(digits: &[u8]) -> Option<u32> {
digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?)))
}
/// Run-length encoding (PackBits: what PostScript's `RunLengthDecode` reads), shared with TIFF.
pub(crate) use vectorcraft_render::encode::tiff::packbits;
/// `data` compressed for `FlateDecode` (zlib).
pub(crate) fn deflate(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::with_capacity(data.len() / 2), flate2::Compression::default());
// Writing to a Vec doesn't fail.
let _ = e.write_all(data);
e.finish().unwrap_or_default()
}
/// zlib `data` inflated, when it is valid and at most `max` bytes.
pub(crate) fn inflate(data: &[u8], max: u64) -> Option<Vec<u8>> {
let mut out = vec![];
flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).ok()?;
(out.len() as u64 <= max).then_some(out)
}
#[cfg(test)]
mod tests {
use super::{ascii85, ascii85_decode, num, push_num_places};
#[test]
fn ascii85_first_line_never_starts_with_percent() {
let data = [0x0C, 0x80, 0, 0, 1, 2, 3, 4];
let s = ascii85(&data);
assert!(s.lines().all(|l| !l.starts_with('%')), "got {s:?}");
assert_eq!(ascii85_decode(&s).as_deref(), Some(&data[..]));
}
#[test]
fn huge_finite_numbers_are_valid_postscript() {
for v in [1.5e305, -1.5e305, f64::MAX, f64::MIN] {
let s = num(v);
assert!(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.'), "{v}: got {s:?}");
}
}
#[test]
fn numbers_keep_the_decimals_asked_for() {
let places = |v: f64, n: u32| {
let mut s = String::new();
push_num_places(&mut s, v, n);
s
};
assert_eq!(num(0.123_456_78), "0.1235");
assert_eq!(places(0.123_456_78, 7), "0.1234568");
assert_eq!(places(f64::from(0.4f32), 7), "0.4");
assert_eq!(places(2.5, 7), "2.5");
assert_eq!(places(f64::NAN, 7), "0");
}
}