mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 02:10:49 -05:00
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation 8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft saved Lab documents as sRGB TIFFs. - codecs: a Lab channel layout (Lab, LabA) holding a* and b* as PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD importer), with its own Lab <-> sRGB conversion (same formulas as photocraft-color; codecs stays standalone) for formats without Lab, which now warn LabConverted. - TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the tiff crate as RGB and labelled Lab; a* and b* are converted from the file's encoding (signed a / 256a, or offset). - TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab is written with 16 bits (DepthReduced). - io: a Lab TIFF opens as a Lab document; a Lab document with one plain layer is written to TIFF in Lab (other formats still use sRGB). Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its own RGB conversion matches within one level at 8 bits and exactly at 16 bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same L*a*b* values. Fixes #4936 * PSD: a smart object's filter mask shows when the file opens Photoshop stores a smart object's layer pixels with its filters applied but not its filter mask. When it composites, it mixes the unfiltered pixels (cached in FEid) back in through the mask. We showed the stored pixels as they were, so a PSD whose smart filter has a mask opened with the filter everywhere. Only after something re-rendered the smart object did the mask appear, because our re-render mixes it. - compose::filter_mask holds the mix, moved from the engine, and its inverse. - Import decodes the unfiltered pixels from FEid (smart_map::unfiltered_from_item) and keeps the mixed result as the smart object's pixels, as the engine's re-render does. - Export writes the layer record the way Photoshop does: unmixed with the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes back to what the document showed. Transparent pixels stay empty. Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148. psd-tools and io corpora unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11) DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr stream for three-sample LinearRaw. The decoder arm from #2021 read the streams but widened every sample by 257, so the values no longer matched the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped everything to white and a 256-entry LinearizationTable was indexed past its end. It also required the JPEG frame to match the clipped row count, which rejected the padded edge tiles real tiled files carry. The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit data) and the LinearizationTable apply to them exactly as for every other compression. A tile may be coded at the full tile size (edge tiles padded, as the spec says) or at the image's row count; a strip at its row count; the width must be the segment's. BitsPerSample must be 8 and the JPEG's component count must equal SamplesPerPixel (1 or 3); anything else is an error, never a silent conversion. The frame header is checked before any pixel is decoded and the JPEG decoder's own size cap is the segment the IFD declares (already charged against the limits), so a hostile SOF cannot size a buffer past the tile. testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles and strips at 37x23 (partial edge tiles, a short last strip), the default white level, black/white scaling and a LinearizationTable against the uncompressed path, and hostile inputs: a truncated tile, frame-size and component-count mismatches, a 65535x65535 SOF under default and tight limits, a tile over the allocation budget, and 12/16-bit or 4-sample files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw file. The parity doc's DNG row is updated; the scorecard row is left for the coordinating branch. * Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. * File > Save As / Save a Copy / CLI can write PDF: one flattened page with the embedded profile (#3401) * Codecs: open Cineon (.cin) film frames read-only Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from the same Kodak header family: the generic header (big-endian, orientation at 192, channel count at 193, the channel-1 bits/dimensions), one or three channels, three 10-bit samples packed into each 32-bit word on a word boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the file's log black/white points are a display choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 is Unsupported; truncation errors rather than panicking. Read-only (no Cineon writer), so .cin joins the open filters and Cineon read closes part of #1049's G10 gap. * Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) - flatten: a path's flattened points are capped at 1 << 20; past it every curve keeps a proportional share of its pieces (at least one), so hostile handles from files (vmsk, SVG, effect outlines) cannot exhaust memory. fill_rasterizer flattens the whole path under one budget. - stroke: past the dash step budget (or below coordinate precision), dash_polyline keeps the dashes made so far and draws the rest solid instead of dropping the stroke. Open polylines are no longer copied. - raster: coordinates are clamped to +-1e15 and edges with a non-finite slope (vanishingly short) are skipped, so huge finite coordinates no longer produce NaN coverage. fill_rasterizer sorts edges once instead of after every component (stable sort, identical order). - Cleanups: dead miter `half`, dead loop-length check in trace_mask, shared mask render body, map_knots helper, shared straight-segment test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) - Patterns: one PatternBudget (decoded bytes + zero padding) is shared by every pattern of a block, a .pat file and the pattern blocks of one document; .abr patt sections are charged to the samples' budget. - Patterns: a channel whose depth differs from the first is rejected (the parsed pattern could not be written back). - Slices: descriptor numbers no longer wrap; out-of-range ids are no id, coordinates clamp to i32, and a v6 trailing entry without a usable id attaches its outsets to no slice. - LayerInfo::unpadded_len is computed from field sizes instead of serializing every record into a scratch buffer. - abr desc presets are moved out instead of cloned; UnFl and shmd counts use the bounded preallocation; ImageData::decode_channel (decoded every plane to return one, unused outside its test) is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Vector: bound stroke outline points (#5211) - stroke_polygons dashes all lines first, then counts round caps and joins. When their full-resolution tessellation would exceed 1 << 20 outline points, every round cap and join gets the same smaller segment count (down to 4 per circle, 1 per join; area-exact radius kept). Strokes under the budget are unchanged; no piece is dropped. - The dash step budget (100000) is now shared by all lines of a stroke instead of per polyline, so many subpaths cannot multiply the dash (and cap) count. Past it the rest is drawn solid, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * PSD: write tiff blocks without copies, inflate merged ZIP once - ImageSourceData::to_bytes writes the global mask and global blocks straight from the model with 4-byte alignment instead of cloning each block (data included) to set its padding. Same bytes. - The tiff transcoder's nested regions and blocks write into the parent's buffer (taken and handed back, error or not) instead of building their own output and copying it up per nesting level. Bodies that fail or are not transcoded are truncated before being copied as stored or dropped. Same bytes. - PsdFile::from_bytes_and_merged decodes the merged image with the parse when asked: a ZIP stream is inflated once (the decode's stream check is the parse's); the parse-only check runs only when the decode fails, to keep from_bytes' errors. PSD import uses it with merged_needed. - PsdBuilder::composite documents that Gray/CMYK composites stay straight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366) Removing long, thin objects (coiled guitar strings, wires) left their outline as darker and lighter blotches and copied pieces of other strings into the fill. The pyramid shrank until the hole's bounding box was four patches across: for a string across the image that is a level where the hole has swallowed its surroundings, so the matches found there copy whatever looks like the blur, and the finer levels (refine-only for wide bounds) keep them. A hole at most a quarter as thick as its bounds are long now counts as thin: the pyramid stops while it is still about two patches thick, and that level starts from a membrane across it (over a few pixels, the surroundings themselves) instead of the onion peel, whose exhaustive search is what made a shallow pyramid unaffordable. Compact holes are unchanged bit for bit. Refs #1366 * perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(history): count snapshot-only tiles against the history budget History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366) Two more causes of ghosts when removing strings and the small knobs selected with them: * Auto sampling summarises the selection's surroundings by a few clusters. Around a long selection those come from the strings' surroundings, so a small part elsewhere (a tuning knob in a differently coloured corner) lost its own surroundings and was filled from far away in another colour. Around a thin selection the Auto area now always reaches as far as the selection is thick. * The membrane that starts a thin hole now starts a patch radius out, past the anti-aliased fringe or halo that a selection drawn close to the object leaves outside it. Refs #1366 * Content-Aware Fill: peel a thin hole where that stays affordable (#1366) Starting every thin hole from a membrane smoothed Spot Healing on film grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain 9.0 against 12.1 around). The onion peel copies the grain; it was only given up because its exhaustive search is too slow on the big level a long thin hole stops at. It now starts the thin hole whenever hole cells times sources stay within 2^28 comparisons, and the membrane only beyond. Refs #1366 * Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open Users on #2788 reported Select Subject dropping clothes that are close in colour to a plain wall and bridging the gap between an arm and the torso. Both come from the same place: the saliency pass labels low-contrast parts of the subject as background, the background colour model then learns them and explains them away, and the final blind hole fill closes every enclosed gap up to 5 % of the mask. Two changes in crates/algo: - A second GrabCut pass whose background model only knows the backdrop. A colour model fitted to the image frame (minus a margin around the salient blob, in case the subject runs out of the picture) marks every pixel it explains as probable background; the blob and whatever the backdrop cannot account for start as probable foreground. Only components that touch the first pass's result survive. - Hole filling that consults the colour models: specks (<= 0.2 % of the mask) always fill, larger holes up to 5 % fill only when their pixels fit the foreground model better than the background's. Tests: two regressions (a cream-shouldered figure on a near-white wall keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus ignored timing and picture-dump helpers. The 6 MP first run goes from about 100 ms to about 200 ms on one core; the P29 repeat path is untouched. * Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2) Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults), and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes Photoshop's HTML list of every bound command by menu. edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use and whether it is modified. Bad names, keys or counts are errors that change nothing. * .kys import: tool keys and sets saved on Windows (K3) Photoshop's <tool> rows now import: each matches our tool by name (our tool names are Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own becomes its override, while its own letter keeps the tool cycling with the others (#5036). A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that would also mean Control on a Mac. The import reports `windows`. * cargo fmt the extension lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gert Pellin <gert@pellin.be> Co-authored-by: timothydaly1 <tadaly04@protonmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com> Co-authored-by: vangel76 <vangel1976@googlemail.com> Co-authored-by: Finn Newick <finnnk@gmail.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Diego Chagas <drochachagas@gmail.com> Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
This commit is contained in:
8 files changed
+369
-10
No files matched your search
@@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
/// keeps defaulting to PSD.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PSD Document", &["psd", "psb"]),
|
||||
("PDF", &["pdf"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
@@ -725,7 +726,8 @@ mod tests {
|
||||
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
|
||||
#[test]
|
||||
fn save_as_offers_only_writable_formats_including_ico() {
|
||||
let layered = ["psd", "psb", "pcraft", "ora"];
|
||||
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
|
||||
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| !layered.contains(e)) {
|
||||
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
|
||||
|
||||
@@ -51,6 +51,7 @@ mod multichannel_map;
|
||||
mod ora;
|
||||
pub use ora::losses as ora_losses;
|
||||
pub mod pattern_map;
|
||||
pub mod pdf;
|
||||
mod pdn;
|
||||
pub mod pixels;
|
||||
mod psd_export;
|
||||
@@ -391,6 +392,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: &
|
||||
if ext == "ora" {
|
||||
return ora::export(doc);
|
||||
}
|
||||
if ext == "pdf" {
|
||||
return pdf::export(doc);
|
||||
}
|
||||
if ext == photocraft_format::EXTENSION {
|
||||
let doc = named_for_file(doc, name_or_ext);
|
||||
let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256));
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The PDF writer (ISO 32000-1 basics): one raster page per file.
|
||||
//!
|
||||
//! A small, self-contained writer used by two paths: File › Save a Copy / Save As › PDF (#3401)
|
||||
//! through [`export`], and File › Print through [`print_pdf`] (called by the engine, which adds
|
||||
//! paper geometry, printer marks and labels around the image). One page holds a
|
||||
//! Flate-compressed image XObject, optional printer marks as vector operators and optional
|
||||
//! Helvetica text; an embedded ICC profile rides in an ICCBased colour space. Multi-page PDF,
|
||||
//! layer-preserving PDF and PDF import stay open (FILE-215-10).
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::Document;
|
||||
|
||||
use crate::{ExportResult, IoError};
|
||||
|
||||
/// The image placed on a page: 8-bit interleaved samples, 1 (gray), 3 (RGB) or 4 (CMYK,
|
||||
/// 1 = full ink), with the profile it was authored in if any.
|
||||
pub struct PrintImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub channels: usize,
|
||||
pub data: Vec<u8>,
|
||||
pub icc: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Printer marks around the image.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Marks {
|
||||
pub corner_crop: bool,
|
||||
pub center_crop: bool,
|
||||
pub registration: bool,
|
||||
}
|
||||
|
||||
/// One page: paper (pt), image box (pt, origin bottom-left), marks and texts.
|
||||
pub struct PrintPage {
|
||||
pub paper: (f64, f64),
|
||||
pub image: PrintImage,
|
||||
pub rect: (f64, f64, f64, f64),
|
||||
pub marks: Marks,
|
||||
pub description: Option<String>,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
fn pdf_string(s: &str) -> String {
|
||||
let mut o = String::from("(");
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'(' | ')' | '\\' => {
|
||||
o.push('\\');
|
||||
o.push(c);
|
||||
}
|
||||
c if c.is_ascii() && !c.is_ascii_control() => o.push(c),
|
||||
_ => o.push('?'),
|
||||
}
|
||||
}
|
||||
o.push(')');
|
||||
o
|
||||
}
|
||||
|
||||
fn deflate(data: &[u8]) -> Vec<u8> {
|
||||
// Fast: print PDFs are transient and large; level 1 is ~4× quicker than the default.
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
let _ = e.write_all(data);
|
||||
e.finish().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Vector marks around `(x, y, w, h)`.
|
||||
fn marks_ops(m: Marks, (x, y, w, h): (f64, f64, f64, f64)) -> String {
|
||||
let mut s = String::from("q 0 G 0.3 w\n");
|
||||
let (gap, len) = (6.0, 18.0);
|
||||
let line = |s: &mut String, x0: f64, y0: f64, x1: f64, y1: f64| s.push_str(&format!("{x0:.2} {y0:.2} m {x1:.2} {y1:.2} l S\n"));
|
||||
if m.corner_crop {
|
||||
for (cx, cy, dx, dy) in [(x, y, -1.0, -1.0), (x + w, y, 1.0, -1.0), (x, y + h, -1.0, 1.0), (x + w, y + h, 1.0, 1.0)] {
|
||||
line(&mut s, cx + dx * gap, cy, cx + dx * (gap + len), cy);
|
||||
line(&mut s, cx, cy + dy * gap, cx, cy + dy * (gap + len));
|
||||
}
|
||||
}
|
||||
if m.center_crop {
|
||||
let (mx, my) = (x + w / 2.0, y + h / 2.0);
|
||||
line(&mut s, mx, y - gap, mx, y - gap - len);
|
||||
line(&mut s, mx, y + h + gap, mx, y + h + gap + len);
|
||||
line(&mut s, x - gap, my, x - gap - len, my);
|
||||
line(&mut s, x + w + gap, my, x + w + gap + len, my);
|
||||
}
|
||||
if m.registration {
|
||||
let r = 5.0;
|
||||
let off = gap + len / 2.0;
|
||||
for (cx, cy) in [(x + w / 2.0, y - off), (x + w / 2.0, y + h + off), (x - off, y + h / 2.0), (x + w + off, y + h / 2.0)] {
|
||||
// Circle (four Béziers) plus crosshair.
|
||||
let k = 0.5523 * r;
|
||||
s.push_str(&format!(
|
||||
"{:.2} {cy:.2} m {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c {:.2} {:.2} {:.2} {:.2} {cx:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {cy:.2} c S\n",
|
||||
cx + r, cx + r, cy + k, cx + k, cy + r, cy + r, cx - k, cy + r, cx - r, cy + k, cx - r, cx - r, cy - k, cx - k, cy - r, cy - r, cx + k, cy - r, cx + r, cy - k, cx + r
|
||||
));
|
||||
line(&mut s, cx - r * 1.6, cy, cx + r * 1.6, cy);
|
||||
line(&mut s, cx, cy - r * 1.6, cx, cy + r * 1.6);
|
||||
}
|
||||
}
|
||||
s.push_str("Q\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// A one-page PDF.
|
||||
pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();
|
||||
let mut offsets: Vec<usize> = Vec::new();
|
||||
let mut obj = |out: &mut Vec<u8>, body: &[u8]| {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", offsets.len()).as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
let img = &page.image;
|
||||
let (pw, ph) = page.paper;
|
||||
let (x, y, w, h) = page.rect;
|
||||
// 1 catalog, 2 pages, 3 page, 4 contents, 5 image, 6 font, 7 ICC (optional).
|
||||
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
obj(
|
||||
&mut out,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
|
||||
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
|
||||
content.push_str(&marks_ops(page.marks, page.rect));
|
||||
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
|
||||
let pad = if any_marks { 30.0 } else { 6.0 };
|
||||
if let Some(l) = page.label.as_deref().filter(|l| !l.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y + h + pad, pdf_string(l)));
|
||||
}
|
||||
if let Some(d) = page.description.as_deref().filter(|d| !d.is_empty()) {
|
||||
content.push_str(&format!("BT /F1 8 Tf 0 g {:.2} {:.2} Td {} Tj ET\n", x, y - pad - 8.0, pdf_string(d)));
|
||||
}
|
||||
let c = deflate(content.as_bytes());
|
||||
let mut body = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", c.len()).into_bytes();
|
||||
body.extend_from_slice(&c);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
let device = match img.channels {
|
||||
1 => "/DeviceGray",
|
||||
4 => "/DeviceCMYK",
|
||||
_ => "/DeviceRGB",
|
||||
};
|
||||
let cs = if img.icc.is_some() { "[/ICCBased 7 0 R]".to_string() } else { device.to_string() };
|
||||
let z = deflate(&img.data);
|
||||
let mut body = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {cs} /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
z.len()
|
||||
)
|
||||
.into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
obj(&mut out, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
||||
if let Some(icc) = &img.icc {
|
||||
let z = deflate(icc);
|
||||
let mut body = format!("<< /N {} /Alternate {device} /Filter /FlateDecode /Length {} >>\nstream\n", img.channels, z.len()).into_bytes();
|
||||
body.extend_from_slice(&z);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
obj(&mut out, &body);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
|
||||
for o in &offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// File › Save As / Save a Copy › PDF (#3401): the flattened document, at its print size
|
||||
/// (pixels ÷ resolution × 72 pt), as a one-page PDF. Layers and transparency are dropped — the
|
||||
/// warning says so — and the embedded profile travels with the image.
|
||||
pub fn export(doc: &Document) -> std::result::Result<ExportResult, IoError> {
|
||||
let (w, h) = (usize::try_from(doc.size.width).unwrap_or(0), usize::try_from(doc.size.height).unwrap_or(0));
|
||||
if w == 0 || h == 0 {
|
||||
return Err(IoError::Unsupported("PDF needs a document with non-zero width and height; nothing was written".into()));
|
||||
}
|
||||
let device = match doc.mode {
|
||||
ColorMode::Rgb => 3,
|
||||
ColorMode::Grayscale => 1,
|
||||
ColorMode::Cmyk => 4,
|
||||
m => return Err(IoError::Unsupported(format!("PDF saves RGB, Grayscale and CMYK documents; {m:?} needs converting to RGB first (Image > Mode)"))),
|
||||
};
|
||||
let fmt = doc.pixel_format();
|
||||
let n = fmt.channels().max(1);
|
||||
let canvas = photocraft_geom::Rect::new(0, 0, w as i32, h as i32);
|
||||
let surface = photocraft_compose::flatten_to_surface(doc, PixelFormat::new(fmt.mode, fmt.sample, true), None);
|
||||
let values = surface.read_region(canvas);
|
||||
let to8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
// Straight alpha, composited here over paper: white in RGB/Gray (1 = full light), zero ink
|
||||
// in CMYK (0 = paper).
|
||||
let paper = if doc.mode == ColorMode::Cmyk { 0.0 } else { 1.0 };
|
||||
let mut data = Vec::with_capacity((w * h).saturating_mul(device));
|
||||
for px in values.chunks(n) {
|
||||
let a = px.last().copied().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
for v in px.iter().take(device) {
|
||||
data.push(to8(v * a + paper * (1.0 - a)));
|
||||
}
|
||||
}
|
||||
let image = PrintImage { width: doc.size.width, height: doc.size.height, channels: device, data, icc: doc.icc_profile.as_ref().map(|p| p.to_vec()) };
|
||||
let dpi = f64::from(doc.resolution_dpi.max(1.0));
|
||||
let (pw, ph) = (f64::from(doc.size.width) * 72.0 / dpi, f64::from(doc.size.height) * 72.0 / dpi);
|
||||
let page = PrintPage { paper: (pw, ph), image, rect: (0.0, 0.0, pw, ph), marks: Marks::default(), description: None, label: None };
|
||||
let mut warnings = vec!["PDF was saved flattened: layers, masks-as-editable and transparency are not preserved (#3401)".into()];
|
||||
if !doc.metadata.text.is_empty() {
|
||||
warnings.push("free-form text metadata is not supported by PDF; it will be dropped".into());
|
||||
}
|
||||
Ok(ExportResult { bytes: print_pdf(&page), warnings })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,131 @@
|
||||
//! PDF export: structure, pixel oracle, colour modes and refusal paths.
|
||||
|
||||
use super::*;
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_doc::{Layer, LayerContent, Size};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn solid(fmt: PixelFormat, x0: i32, y0: i32, x1: i32, y1: i32, px: &[f32]) -> Surface {
|
||||
let mut s = Surface::new(fmt);
|
||||
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
|
||||
let data: Vec<f32> = px.iter().copied().cycle().take(w * h * px.len()).collect();
|
||||
s.write_region(Rect::from_xywh(x0, y0, w as u32, h as u32), &data);
|
||||
s
|
||||
}
|
||||
|
||||
/// The opaque paper colour of `mode`, with alpha.
|
||||
fn paper(mode: ColorMode) -> Vec<f32> {
|
||||
match mode {
|
||||
ColorMode::Grayscale => vec![1.0, 1.0],
|
||||
ColorMode::Cmyk => vec![0.0, 0.0, 0.0, 0.0, 1.0],
|
||||
_ => vec![1.0, 1.0, 1.0, 1.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn doc(mode: ColorMode) -> Document {
|
||||
let fmt = PixelFormat::new(mode, SampleType::F32, true);
|
||||
let bg = paper(mode);
|
||||
let mut d = Document::new("t.pdf", Size { width: 4, height: 2 }, mode, SampleType::F32);
|
||||
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 4, 2, &bg)))];
|
||||
d
|
||||
}
|
||||
|
||||
/// The bytes of the stream following `marker` (e.g. "/Subtype /Image"), inflated.
|
||||
fn stream_after(pdf: &[u8], marker: &str) -> Vec<u8> {
|
||||
let at = pdf.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("marker");
|
||||
let body = &pdf[at..];
|
||||
let len_at = body.windows(8).position(|w| w == b"/Length ").expect("length") + 8;
|
||||
let len_end = body[len_at..].iter().position(|b| !b.is_ascii_digit()).expect("length end");
|
||||
let len: usize = std::str::from_utf8(&body[len_at..len_at + len_end]).unwrap().parse().unwrap();
|
||||
let start = body.windows(7).position(|w| w == b"stream\n").expect("stream") + 7;
|
||||
let mut out = Vec::new();
|
||||
std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[start..start + len]), &mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn has(pdf: &[u8], needle: &str) -> bool {
|
||||
pdf.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
fn check_structure(pdf: &[u8]) {
|
||||
assert!(pdf.starts_with(b"%PDF-1."), "PDF header");
|
||||
assert!(pdf.ends_with(b"%%EOF\n"), "EOF marker");
|
||||
let start = pdf.windows(9).rposition(|w| w == b"startxref").unwrap() + 9;
|
||||
let off: usize = std::str::from_utf8(&pdf[start..]).unwrap().split_whitespace().next().unwrap().parse().unwrap();
|
||||
assert_eq!(&pdf[off..off + 4], b"xref", "startxref points at the xref table");
|
||||
// Every xref offset really precedes its `n 0 obj` header. The xref/trailer region is pure
|
||||
// ASCII, so it is safe to decode just that slice.
|
||||
let table = std::str::from_utf8(&pdf[off..]).unwrap().lines().collect::<Vec<_>>();
|
||||
let count: usize = table[1].split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
for (i, line) in table.iter().skip(3).take(count - 1).enumerate() {
|
||||
let o: usize = line[..10].parse().unwrap();
|
||||
let want = format!("{} 0 obj", i + 1);
|
||||
assert_eq!(&pdf[o..o + want.len()], want.as_bytes(), "xref offset for object {}", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_rgb_document_saves_as_one_page_pdf() {
|
||||
let d = doc(ColorMode::Rgb);
|
||||
let out = export(&d).unwrap();
|
||||
let pdf = &out.bytes;
|
||||
check_structure(pdf);
|
||||
assert!(has(pdf, "/MediaBox [0 0 4.00 2.00]"), "page = image size at 72 pt/in");
|
||||
assert!(has(pdf, "/Width 4 /Height 2 /ColorSpace /DeviceRGB"), "RGB is DeviceRGB");
|
||||
assert_eq!(stream_after(pdf, "/Subtype /Image"), vec![255u8; 4 * 2 * 3], "the white paper, 8-bit");
|
||||
assert!(out.warnings.iter().any(|w| w.contains("flattened")), "layers are not preserved");
|
||||
// Through the general dispatch as well.
|
||||
let via = crate::export(&d, "out.pdf", &crate::ExportOptions::default()).unwrap();
|
||||
assert_eq!(via.bytes, *pdf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_composite_over_white_before_the_page() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
d.layers.push(Layer::new("Half red", LayerContent::Raster(solid(fmt, 1, 0, 3, 2, &[1.0, 0.0, 0.0, 0.5]))));
|
||||
let out = export(&d).unwrap();
|
||||
let img = stream_after(&out.bytes, "/Subtype /Image");
|
||||
// Pixels 1-2 of row 0: 0.5 red over white = (1.0, 0.5, 0.5); elsewhere plain white.
|
||||
assert_eq!(&img[3..6], &[255, 128, 128], "the blend lands: {:?}", &img[3..6]);
|
||||
assert_eq!(&img[0..3], &[255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn greyscale_and_cmyk_pages_keep_their_channels() {
|
||||
let g = export(&doc(ColorMode::Grayscale)).unwrap();
|
||||
assert!(has(&g.bytes, "/ColorSpace /DeviceGray"), "gray is DeviceGray");
|
||||
assert_eq!(stream_after(&g.bytes, "/Subtype /Image"), vec![255; 4 * 2], "one sample per pixel");
|
||||
let c = export(&doc(ColorMode::Cmyk)).unwrap();
|
||||
assert!(has(&c.bytes, "/ColorSpace /DeviceCMYK"), "cmyk is DeviceCMYK");
|
||||
assert_eq!(stream_after(&c.bytes, "/Subtype /Image"), vec![0; 4 * 2 * 4], "zero ink over the whole page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_measures_at_the_document_resolution() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.size = Size { width: 300, height: 150 };
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
d.layers = vec![Layer::new("Paper", LayerContent::Raster(solid(fmt, 0, 0, 300, 150, &[1.0, 1.0, 1.0, 1.0])))];
|
||||
d.resolution_dpi = 300.0;
|
||||
let out = export(&d).unwrap();
|
||||
assert!(has(&out.bytes, "/MediaBox [0 0 72.00 36.00]"), "300 px at 300 dpi is one inch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_profile_travels_as_iccbased() {
|
||||
let mut d = doc(ColorMode::Rgb);
|
||||
d.icc_profile = Some(std::sync::Arc::new(vec![1, 2, 3, 4, 5, 6, 7, 8]));
|
||||
let out = export(&d).unwrap();
|
||||
assert!(has(&out.bytes, "[/ICCBased 7 0 R]"), "the image refers to the profile");
|
||||
assert_eq!(stream_after(&out.bytes, "/N 3 /Alternate"), vec![1, 2, 3, 4, 5, 6, 7, 8], "the profile bytes survive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_modes_are_refused_with_an_actionable_error() {
|
||||
let d = doc(ColorMode::Lab);
|
||||
let e = export(&d).unwrap_err().to_string();
|
||||
assert!(e.contains("RGB") && e.contains("Mode"), "{e}");
|
||||
assert!(crate::export(&d, "x.pdf", &crate::ExportOptions::default()).is_err());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
||||
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
||||
@@ -38,7 +38,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity.
|
||||
| Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f |
|
||||
| FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer |
|
||||
| Targa | R/W | yes | yes | 8-bit |
|
||||
| Photoshop PDF | R/W | no (#2541) | raster only (print, artboards to PDF) | No vector/text PDF, no multi-page save, no PDF presentation (FILE-215-10) |
|
||||
| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) |
|
||||
| Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | |
|
||||
| Generic EPS / AI | R (rasterize) | no | no | |
|
||||
| SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) |
|
||||
@@ -172,4 +172,5 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
||||
| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) |
|
||||
| 2026-10-11 | minor | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel |
|
||||
| 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |
|
||||
| 2026-10-11 | minor | PDF write now covers Save As / Save a Copy / CLI (`io::pdf::export`, #3401): one flattened RGB/Gray/CMYK page at the document's print size with the embedded profile. Read (#2541), multi-page save, PDF presentation, vector/text output still open
|
||||
| 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` |
|
||||
+5
-3
@@ -222,8 +222,9 @@ what an agent can't finish alone.
|
||||
## File-format gaps
|
||||
|
||||
### G10. Missing and partial file formats
|
||||
- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF
|
||||
(FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047),
|
||||
- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page
|
||||
write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI,
|
||||
2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047),
|
||||
Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop
|
||||
Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl,
|
||||
.shc, .csh presets (FILE-215-15); C2PA (#1050).
|
||||
@@ -307,6 +308,7 @@ what an agent can't finish alone.
|
||||
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
|
||||
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |
|
||||
| 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing |
|
||||
| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open |
|
||||
| 2026-10-11 | minor | G10: one-page flattened PDF write shipped (io::pdf, #3401); FILE-215-10 now partial |
|
||||
| 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed |
|
||||
| 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues |
|
||||
+2
-2
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 |
|
||||
| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 33 | 11 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) |
|
||||
| [Type](#type) | 4 | 3 | 2 | |
|
||||
@@ -107,7 +107,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) |
|
||||
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet |
|
||||
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain |
|
||||
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
|
||||
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open |
|
||||
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
|
||||
|
||||
@@ -71,9 +71,9 @@ note = "io svg.rs: paths, groups, text outlines as shape layers (gradients appro
|
||||
[[item]]
|
||||
id = "FILE-215-10"
|
||||
target = "Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "Only print_cmds.rs:133 print_pdf (raster print page)"
|
||||
note = "io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-11"
|
||||
|
||||
Reference in new issue
Block a user