Compare commits

...
Author SHA1 Message Date
Claude b89557afaf rustfmt: order the io module list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 21:48:55 +00:00
Diego Chagas 49d37c0136 PSD type layers without pixels are rendered on import; PostScript font names resolve to the installed family (#1405)
ag-psd, GIMP and other writers leave a PSD type layer without image data and
let Photoshop re-render it on open. The importer made those empty pixels the
layer's cache, so the canvas showed nothing until the layer was edited, and
the family guessed from the PostScript name ("CC Wild Words" for
CCWildWords-Regular) never got corrected to the installed family
("CCWildWords"), so Missing Fonts replaced an installed font.

- crates/io/src/psd_import.rs: a type layer's record pixels are the cache only
  when there are some.
- crates/io/src/text_import.rs (new, run by import): resolves each run's
  PostScript name with FontDb::resolve_postscript, as the layout does, and
  writes the installed family, weight and italic back into the run; renders
  every type layer that has no cache from its model.
- crates/io/tests/psd_type_bounds.rs: a type layer exported without pixels
  imports rendered, on the engine's own pixels; "JetBrainsMono-Regular" (guess
  "Jet Brains Mono") imports as the family "JetBrains Mono".

Fixes #1405
2026-10-08 21:48:54 +00:00
4 changed files with 114 additions and 4 deletions

No files matched your search

+2
View File
@@ -42,6 +42,7 @@ pub mod raw;
pub mod slices_map;
pub mod smart_map;
pub mod svg;
mod text_import;
pub mod text_styles_map;
pub mod tiff_layers;
pub mod vector_map;
@@ -175,6 +176,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -
ctl.progress(0.05);
let (mut document, warnings) = psd_import::psd_to_document_with(&file, ctl).ok_or(IoError::Cancelled)?;
document.name = name.to_string();
text_import::prepare(&mut document);
return Ok(ImportResult { document, warnings });
}
if raw::is_raw(bytes) {
+6 -1
View File
@@ -289,7 +289,12 @@ impl Ctx<'_> {
if let Some(txt2) = &self.txt2 {
photocraft_text::psd::apply_txt2(&mut t, &data, txt2);
}
t.cache = Some(self.record_surface(rec, &name));
// Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and
// other writers leave type layers without image data (Photoshop re-renders them on
// open), and an empty cache would show nothing until the layer is edited; the import
// renders such a layer from its model instead (`text_import::prepare`).
let cache = self.record_surface(rec, &name);
t.cache = (!cache.content_bounds().is_empty()).then_some(cache);
t.psd_raw = principal(b"TySh");
LayerContent::Text(t)
} else if let Some(k) = smart_key {
+47
View File
@@ -0,0 +1,47 @@
//! Type layers read from a file, made ready for the canvas and the panels.
use photocraft_doc::{Document, Layer, LayerContent};
/// Prepares the type layers of a document that was just imported:
/// - a run whose font came as a PostScript name (PSD files) gets the installed family that name
/// resolves to, as the layout does, so Missing Fonts and the Character panel see the real
/// family (`CCWildWords-Regular` is the family "CCWildWords", not the guessed "CC Wild Words");
/// - a type layer without pixels (ag-psd, GIMP and other writers leave the image data out and
/// let Photoshop re-render on open) is rendered from its model, instead of staying empty
/// until it is edited.
pub(crate) fn prepare(doc: &mut Document) {
fn visit(layers: &mut [Layer], eng: &mut photocraft_text::TextEngine, dpi: f32, format: photocraft_color::PixelFormat) {
for l in layers {
match &mut l.content {
LayerContent::Text(t) => {
let mut changed = false;
for r in &mut t.runs {
if let Some(ps) = r.style.postscript_name.clone()
&& !eng.fonts.has_family(&r.style.font_family)
{
let f = eng.fonts.resolve_postscript(&ps);
if f.exact || eng.fonts.has_family(&f.family) {
r.style.font_family = f.family;
r.style.weight = f.weight;
r.style.italic = f.italic;
changed = true;
}
}
}
if changed {
t.sync_summary();
}
if t.cache.is_none() {
eng.render_layer(t, dpi, format);
}
}
LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format),
_ => {}
}
}
}
let dpi = doc.resolution_dpi;
let format = doc.pixel_format();
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
visit(&mut doc.layers, &mut eng, dpi, format);
}
+59 -3
View File
@@ -43,7 +43,7 @@ fn style(size: f64, leading: Option<f64>) -> E {
}
/// Photoshop-shaped EngineData for `text` (paragraphs separated by `\r`).
fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>) -> E {
fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, font: &str) -> E {
let ed_text = format!("{text}\r");
let n16 = ed_text.encode_utf16().count() as i64;
let mut sruns = Vec::new();
@@ -63,7 +63,7 @@ fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>) -> E {
("Cookie".into(), E::Dict(vec![("Photoshop".into(), E::Dict(photoshop))])),
]);
let resources = E::Dict(vec![
("FontSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Inter-Regular".into())), ("Script".into(), E::Int(0))])])),
("FontSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String(font.into())), ("Script".into(), E::Int(0))])])),
("StyleSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("StyleSheetData".into(), style(12.0, None))])])),
("ParagraphSheetSet".into(), E::Array(vec![E::Dict(vec![("Name".into(), E::String("Normal RGB".into())), ("Properties".into(), para.clone())])])),
]);
@@ -89,6 +89,11 @@ fn engine_data(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>) -> E {
/// A `TySh` block as Photoshop writes it.
fn tysh(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transform: Affine, ink: [f64; 4]) -> Vec<u8> {
tysh_with_font(text, runs, box_bounds, transform, ink, "Inter-Regular")
}
/// A `TySh` block whose runs use the font with PostScript name `font`.
fn tysh_with_font(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transform: Affine, ink: [f64; 4], font: &str) -> Vec<u8> {
let rect = |cls: &str| {
D::Descriptor(
Descriptor::new(cls)
@@ -106,7 +111,7 @@ fn tysh(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transform: Affin
.with("bounds", rect("bounds"))
.with("boundingBox", rect("boundingBox"))
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds))));
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds, font))));
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
}
@@ -300,3 +305,54 @@ fn point_text_height_follows_the_type_size() {
let ratio = drawn300.height() as f32 / drawn.height() as f32;
assert!((ratio - 300.0 / 72.0).abs() < 0.2, "300 dpi H {} vs 72 dpi H {}", drawn300.height(), drawn.height());
}
#[test]
fn type_layers_without_pixels_are_rendered_on_import() {
// ag-psd, GIMP and other writers leave type layers without image data (Photoshop re-renders
// them on open). The model must import and the layer must be drawn from it, not shown as
// empty pixels until it is edited.
let c = &cases()[0];
let mut doc = Document::new("t", Size::new(900, 700), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = c.dpi;
let data = tysh(c.text, &c.runs, c.box_bounds, c.transform, [40.0, 50.0, 400.0, 110.0]);
let mut t = photocraft_text::psd::text_layer_from_tysh(&data, c.dpi).unwrap();
t.cache = None;
t.psd_raw = Some(std::sync::Arc::new(data));
doc.layers.push(Layer::new(c.name, LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
assert_eq!(bt.text, c.text);
let drawn = bt.cache.as_ref().expect("rendered on import").content_bounds();
assert!(!drawn.is_empty(), "nothing rendered");
// The same text rendered by the engine lands on the same pixels.
let mut engine = photocraft_text::TextEngine::new();
let (_, ours) = engine.render(bt, back.resolution_dpi, back.pixel_format());
assert!(close(ours.surface.content_bounds(), drawn, 1), "{:?} vs {drawn:?}", ours.surface.content_bounds());
// With pixels in the file, they stay the cache (Photoshop's rendering is authoritative).
let (drawn, t, ..) = import_case(c);
assert_eq!(t.cache.as_ref().map(|s| s.content_bounds()), Some(drawn));
}
#[test]
fn postscript_font_names_resolve_to_the_installed_family_on_import() {
// The family guessed from a PostScript name is not always the installed one: the bundled
// JetBrains Mono is "JetBrainsMono-Regular", which splits into "Jet Brains Mono". The run
// must name the family the font database really has, so nothing reports it missing.
let c = &cases()[0];
let mut doc = Document::new("t", Size::new(900, 700), ColorMode::Rgb, SampleType::U8);
doc.resolution_dpi = c.dpi;
let data = tysh_with_font(c.text, &c.runs, c.box_bounds, c.transform, [40.0, 50.0, 400.0, 110.0], "JetBrainsMono-Regular");
let probe = photocraft_text::psd::text_layer_from_tysh(&data, c.dpi).unwrap();
assert_eq!(probe.runs[0].style.font_family, "Jet Brains Mono", "the guess the import must correct");
let mut t = probe;
t.cache = None;
t.psd_raw = Some(std::sync::Arc::new(data));
doc.layers.push(Layer::new(c.name, LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("t.psd", &bytes).unwrap().document;
let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") };
assert_eq!(bt.runs[0].style.font_family, "JetBrains Mono");
assert_eq!(bt.runs[0].style.postscript_name.as_deref(), Some("JetBrainsMono-Regular"));
assert!(bt.cache.as_ref().is_some_and(|s| !s.content_bounds().is_empty()));
}