Preset panels, character/paragraph styles, Glyphs, Check Spelling
Gradients/Patterns/Styles/Shapes/Tool Presets/Clone Source panels with headless preset commands and a Custom Shape tool; Color card tabs match Essentials. Character and Paragraph Styles (doc model, .pcraft, PSD style sheets), Glyphs panel + type.insertText, Check Spelling (SCOWL-50 list). Checkpoint also includes compiling in-progress work of the remaining agents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa87012aab
commit
ceb2667ac4
|
|
@ -67,9 +67,11 @@ impl Prim {
|
|||
let r = ra.max(rb) + s;
|
||||
(a.0.min(b.0) - r, a.1.min(b.1) - r, a.0.max(b.0) + r, a.1.max(b.1) + r)
|
||||
}
|
||||
Shape::Ellipse { c, rx, ry, .. } => {
|
||||
let r = rx.max(ry) + s;
|
||||
(c.0 - r, c.1 - r, c.0 + r, c.1 + r)
|
||||
Shape::Ellipse { c, rx, ry, angle } => {
|
||||
let (sn, cs) = angle.sin_cos();
|
||||
let ex = ((rx * cs).powi(2) + (ry * sn).powi(2)).sqrt() + s;
|
||||
let ey = ((rx * sn).powi(2) + (ry * cs).powi(2)).sqrt() + s;
|
||||
(c.0 - ex, c.1 - ey, c.0 + ex, c.1 + ey)
|
||||
}
|
||||
};
|
||||
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
|
||||
|
|
|
|||
|
|
@ -226,9 +226,12 @@ pub fn encode_jpeg_rgb8(width: u32, height: u32, rgb: &[u8], quality: u8, progre
|
|||
}
|
||||
let mut out = Vec::new();
|
||||
let mut enc = jpeg_encoder::Encoder::new(&mut out, quality.clamp(1, 100));
|
||||
let subsampled = quality < 90;
|
||||
enc.set_progressive(progressive);
|
||||
enc.set_optimized_huffman_tables(optimized || progressive);
|
||||
enc.set_sampling_factor(if quality >= 90 { jpeg_encoder::SamplingFactor::R_4_4_4 } else { jpeg_encoder::SamplingFactor::R_4_2_0 });
|
||||
// jpeg-encoder 0.6 writes corrupt baseline scans when optimised Huffman tables meet 4:2:0
|
||||
// (both zune-jpeg and image-rs decode them green); progressive output is fine.
|
||||
enc.set_optimized_huffman_tables(progressive || (optimized && !subsampled));
|
||||
enc.set_sampling_factor(if subsampled { jpeg_encoder::SamplingFactor::R_4_2_0 } else { jpeg_encoder::SamplingFactor::R_4_4_4 });
|
||||
if let Some(d) = dpi.filter(|d| *d >= 1.0) {
|
||||
let v = d.round().min(65535.0) as u16;
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
|
||||
|
|
@ -331,3 +334,24 @@ mod tests {
|
|||
assert_eq!(crate::decode(&prog).unwrap().dimensions(), (64, 48));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod subsampling_tests {
|
||||
use super::*;
|
||||
|
||||
/// jpeg-encoder 0.6 writes broken scans for baseline 4:2:0 with optimised Huffman tables;
|
||||
/// `encode_jpeg_rgb8` must avoid that combination.
|
||||
#[test]
|
||||
fn every_option_combination_decodes_to_the_source() {
|
||||
let (w, h) = (64u32, 48u32);
|
||||
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect();
|
||||
for q in [30, 60, 95] {
|
||||
for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap();
|
||||
let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8);
|
||||
let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap();
|
||||
assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -359,8 +359,13 @@ fn lut_at(l: &[f32], v: f32) -> f32 {
|
|||
/// coverage `v` through `contour(min(v / range, 1))`, so the default 50 % doubles a blurred
|
||||
/// edge's 0.5 to full strength). `None` = identity.
|
||||
pub fn glow_lut(g: &Glow) -> Option<Vec<f32>> {
|
||||
let base = contour_lut(&g.contour);
|
||||
let range = if g.range.is_finite() { g.range.clamp(0.01, 1.0) } else { 1.0 };
|
||||
ranged_lut(&g.contour, g.range)
|
||||
}
|
||||
|
||||
/// A contour applied over a Range (`contour(min(v / range, 1))`); `None` = identity.
|
||||
pub fn ranged_lut(contour: &Contour, range: f32) -> Option<Vec<f32>> {
|
||||
let base = contour_lut(contour);
|
||||
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
|
||||
if range >= 0.999 {
|
||||
return base;
|
||||
}
|
||||
|
|
@ -686,7 +691,7 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
|||
/// Bevel height map in `0..=1`. Smooth: the shape blurred by two box passes of the bevel
|
||||
/// width. Chisel: linear ramps of the exact distance to the edge (size wide; emboss styles
|
||||
/// straddle the edge), slightly blurred for Chisel Soft. Soften blurs the result.
|
||||
fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom) -> Map {
|
||||
fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx) -> Map {
|
||||
let size = b.size.max(1.0);
|
||||
let mut h = if b.technique == BevelTechnique::Smooth {
|
||||
let mut h = shape.clone();
|
||||
|
|
@ -703,12 +708,42 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom) -> Map {
|
|||
}
|
||||
h
|
||||
};
|
||||
// Contour element: the height profile through the contour over its range.
|
||||
if let Some(c) = &b.contour {
|
||||
h = apply_lut(h, Some(ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect())));
|
||||
}
|
||||
// Texture element: the pattern's luminance as extra height, scaled so a full-contrast step
|
||||
// at 100 % depth slopes like the bevel at its depth.
|
||||
if let Some(t) = &b.texture
|
||||
&& let Some(tile) = photocraft_doc::pattern::find(tex.patterns, &t.id, &t.name).and_then(Tile::new)
|
||||
{
|
||||
let unit = if b.depth.abs() > 1e-6 { (g.depth / b.depth).abs().max(1e-3) } else { g.width.max(1.0) };
|
||||
let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0);
|
||||
let k = t.depth / unit;
|
||||
let (x0, y0) = (tex.rect.x0, tex.rect.y0);
|
||||
for y in 0..h.h {
|
||||
for x in 0..h.w {
|
||||
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
|
||||
let p = tile.sample(u, v);
|
||||
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
|
||||
h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l };
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.soften >= 1.0 {
|
||||
tent(&mut h, b.soften);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Where a bevel texture tiles: the maps' rectangle, the document's patterns and the linked
|
||||
/// origin (the effects reference point, else the layer's top-left).
|
||||
pub struct TextureCtx<'a> {
|
||||
pub rect: Rect,
|
||||
pub patterns: &'a [Pattern],
|
||||
pub anchor: (f64, f64),
|
||||
}
|
||||
|
||||
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
|
||||
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
|
||||
match paint {
|
||||
|
|
@ -726,9 +761,9 @@ pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32
|
|||
|
||||
/// Bevel highlight and shadow maps: `[hi, sh]` (inner or outer) or `[hi, sh, hi_out, sh_out]`
|
||||
/// (emboss styles).
|
||||
fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight) -> (Vec<Map>, BevelPaint) {
|
||||
fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx) -> (Vec<Map>, BevelPaint) {
|
||||
let g = bevel_geom(b);
|
||||
let hmap = bevel_height(shape, b, &g);
|
||||
let hmap = bevel_height(shape, b, &g, tex);
|
||||
let (angle, altitude) = if b.use_global_light { (light.angle, light.altitude) } else { (b.angle, b.altitude) };
|
||||
let (sa, ca) = angle.to_radians().sin_cos();
|
||||
let (se, ce) = altitude.to_radians().sin_cos();
|
||||
|
|
@ -833,7 +868,7 @@ fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
|
|||
}
|
||||
|
||||
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
|
||||
pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLight) -> FxMaps {
|
||||
pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLight, tex: &TextureCtx) -> FxMaps {
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
let shape = Map { w, h, v: shape };
|
||||
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
|
||||
|
|
@ -848,7 +883,7 @@ pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLigh
|
|||
Effect::InnerGlow(g) => vec![glow_map(&shape, g, true)],
|
||||
Effect::Satin(s) => vec![satin_map(&shape, s)],
|
||||
Effect::BevelEmboss(b) => {
|
||||
let (maps, paint) = bevel_maps(&shape, b, light);
|
||||
let (maps, paint) = bevel_maps(&shape, b, light, tex);
|
||||
bevel_paint[i] = paint;
|
||||
maps
|
||||
}
|
||||
|
|
@ -1211,6 +1246,10 @@ mod tests {
|
|||
assert_eq!(tent_kernel(1.0), (0, vec![1.0]));
|
||||
}
|
||||
|
||||
fn no_tex() -> TextureCtx<'static> {
|
||||
TextureCtx { rect: Rect::new(0, 0, 1, 1), patterns: &[], anchor: (0.0, 0.0) }
|
||||
}
|
||||
|
||||
fn square(n: usize, x0: usize, x1: usize) -> Map {
|
||||
let mut m = Map::new(n, n, 0.0);
|
||||
for y in x0..x1 {
|
||||
|
|
@ -1238,13 +1277,15 @@ mod tests {
|
|||
highlight_color: photocraft_color::Color::WHITE,
|
||||
shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
shadow_color: photocraft_color::Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_inner_bevel_lights_the_top_edge_inside_only() {
|
||||
let shape = square(40, 10, 30);
|
||||
let (maps, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth), &GlobalLight::default());
|
||||
let (maps, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth), &GlobalLight::default(), &no_tex());
|
||||
assert_eq!(paint, BevelPaint::Inner);
|
||||
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
|
||||
// Light from the top: highlight along the top edge, shadow along the bottom, flat middle.
|
||||
|
|
@ -1260,24 +1301,51 @@ mod tests {
|
|||
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
|
||||
let shape = square(40, 10, 30);
|
||||
let l = GlobalLight::default();
|
||||
let (e, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l);
|
||||
let (e, paint) = bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex());
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
|
||||
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
|
||||
// Emboss: the slope across the top edge faces the light on both sides.
|
||||
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[2], 20, 9) > 0.3);
|
||||
let (p, _) = bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l);
|
||||
let (p, _) = bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex());
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chisel_hard_has_flat_facets() {
|
||||
let shape = square(48, 10, 38);
|
||||
let (m, _) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard), &GlobalLight::default());
|
||||
let (m, _) = bevel_maps(&shape, &bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard), &GlobalLight::default(), &no_tex());
|
||||
let at = |x: usize, y: usize| m[0].v[y * 48 + x];
|
||||
// A constant slope: equal highlight along the top facet.
|
||||
assert!(at(24, 12) > 0.1 && (at(24, 12) - at(24, 13)).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_contour_and_texture_shape_the_height() {
|
||||
let shape = square(40, 6, 34);
|
||||
let l = GlobalLight::default();
|
||||
let plain = bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth);
|
||||
let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex());
|
||||
// A contour reshapes the profile (here a ramp folded at its middle).
|
||||
let mut c = plain.clone();
|
||||
let fold = vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 }, photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }];
|
||||
c.contour = Some(photocraft_doc::BevelContour { contour: Contour::Custom { name: "fold".into(), points: fold }, range: 1.0, anti_alias: false });
|
||||
let (b, _) = bevel_maps(&shape, &c, &l, &no_tex());
|
||||
let diff: f32 = a[1].v.iter().zip(&b[1].v).map(|(x, y)| (x - y).abs()).sum();
|
||||
assert!(diff > 5.0, "{diff}");
|
||||
// A striped texture lights the flat middle.
|
||||
let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]);
|
||||
sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let pats = [Pattern::new("stripes", sf, 4, 4)];
|
||||
let mut t = plain.clone();
|
||||
t.texture = Some(photocraft_doc::BevelTexture { name: "stripes".into(), id: String::new(), scale: 1.0, depth: 1.0, invert: false, link: true, phase: (0.0, 0.0) });
|
||||
let tex = TextureCtx { rect: Rect::new(0, 0, 40, 40), patterns: &pats, anchor: (0.0, 0.0) };
|
||||
let (m, _) = bevel_maps(&shape, &t, &l, &tex);
|
||||
let mid: f32 = (16..24).map(|y| m[0].v[y * 40 + 20] + m[1].v[y * 40 + 20]).sum();
|
||||
let flat: f32 = (16..24).map(|y| a[0].v[y * 40 + 20] + a[1].v[y * 40 + 20]).sum();
|
||||
assert!(mid > flat + 0.5, "{mid} vs {flat}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_range_stretches_the_contour() {
|
||||
let mut g = Glow {
|
||||
|
|
|
|||
|
|
@ -695,7 +695,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
|
||||
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
|
||||
let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default();
|
||||
return std::sync::Arc::new(effects::build_maps(layer, shape, region, &cx.light));
|
||||
return std::sync::Arc::new(effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx)));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
layer_identity(layer, &mut h);
|
||||
|
|
@ -719,7 +719,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
} else {
|
||||
render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
};
|
||||
let maps = effects::build_maps(layer, shape, region, &cx.light);
|
||||
let maps = effects::build_maps(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx));
|
||||
let bytes = maps.bytes();
|
||||
// Counted exactly once, when the entry is built.
|
||||
fx_cache().lock().unwrap_or_else(|e| e.into_inner()).bytes += bytes;
|
||||
|
|
@ -742,6 +742,11 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
|||
maps
|
||||
}
|
||||
|
||||
fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> {
|
||||
let sb = layer_bounds(layer, cx.canvas);
|
||||
effects::TextureCtx { rect: region, patterns: cx.patterns, anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) }
|
||||
}
|
||||
|
||||
/// Composite `layer` onto `base` restricted to the base's alpha (clipping mask semantics),
|
||||
/// honouring its channel restrictions.
|
||||
fn composite_atop(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
|
|
|
|||
|
|
@ -532,6 +532,8 @@ fn satin_and_bevel_stay_inside_shape() {
|
|||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.75),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
})]);
|
||||
for (x, y) in [(5, 5), (35, 20), (20, 35)] {
|
||||
assert!(close4(px(&d, x, y), [1.0; 4]), "({x},{y}) {:?}", px(&d, x, y));
|
||||
|
|
|
|||
|
|
@ -196,6 +196,42 @@ pub struct Bevel {
|
|||
pub highlight_color: Color,
|
||||
pub shadow: FxCommon,
|
||||
pub shadow_color: Color,
|
||||
/// Bevel & Emboss › Contour: shapes the bevel's height profile. `None` = off.
|
||||
#[serde(default)]
|
||||
pub contour: Option<BevelContour>,
|
||||
/// Bevel & Emboss › Texture: a pattern's luminance added to the height. `None` = off.
|
||||
#[serde(default)]
|
||||
pub texture: Option<BevelTexture>,
|
||||
}
|
||||
|
||||
/// The Contour element of Bevel & Emboss.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BevelContour {
|
||||
pub contour: Contour,
|
||||
/// Range `0..=1` (Photoshop's default 50 %): the part of the bevel the contour spans.
|
||||
pub range: f32,
|
||||
pub anti_alias: bool,
|
||||
}
|
||||
|
||||
impl Default for BevelContour {
|
||||
fn default() -> Self {
|
||||
BevelContour { contour: Contour::Linear, range: 0.5, anti_alias: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The Texture element of Bevel & Emboss.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BevelTexture {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
/// Scale as a fraction (1 = 100 %).
|
||||
pub scale: f32,
|
||||
/// Depth `-10..=10` (1 = +100 %); negative carves the pattern in.
|
||||
pub depth: f32,
|
||||
pub invert: bool,
|
||||
/// "Link with Layer": tile from the layer (else the canvas origin).
|
||||
pub link: bool,
|
||||
pub phase: (f32, f32),
|
||||
}
|
||||
|
||||
/// One layer effect. Several instances of the same kind are allowed
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pub use adjust::Adjustment;
|
|||
pub use analysis::{CountGroup, Measurement, MeasurementScale, Note, Ruler};
|
||||
pub use comps::{Artboard, ArtboardBackground, CompAppearance, CompLayerState, LayerComp};
|
||||
pub use effects::{
|
||||
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient,
|
||||
Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient,
|
||||
GradientStyle, Satin, Shadow, StrokeFx, StrokePosition,
|
||||
};
|
||||
pub use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
|
|
|
|||
|
|
@ -129,9 +129,14 @@ pub fn effect_from_params(kind: &str, p: &Value) -> Option<Effect> {
|
|||
Some("outer") => BevelStyle::OuterBevel,
|
||||
Some("emboss") => BevelStyle::Emboss,
|
||||
Some("pillow") => BevelStyle::PillowEmboss,
|
||||
Some("stroke") => BevelStyle::StrokeEmboss,
|
||||
_ => BevelStyle::InnerBevel,
|
||||
},
|
||||
technique: BevelTechnique::Smooth,
|
||||
technique: match p.get("technique").and_then(Value::as_str) {
|
||||
Some("chiselHard") => BevelTechnique::ChiselHard,
|
||||
Some("chiselSoft") => BevelTechnique::ChiselSoft,
|
||||
_ => BevelTechnique::Smooth,
|
||||
},
|
||||
depth: f(p, "depth", 100.0) / 100.0,
|
||||
up: p.get("direction").and_then(Value::as_str) != Some("down"),
|
||||
size: f(p, "size", 5.0),
|
||||
|
|
@ -144,6 +149,8 @@ pub fn effect_from_params(kind: &str, p: &Value) -> Option<Effect> {
|
|||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
}),
|
||||
_ => return None,
|
||||
})
|
||||
|
|
@ -208,7 +215,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
|
||||
style_cmd!("gradientOverlay", "Gradient Overlay…", r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##),
|
||||
style_cmd!("patternOverlay", "Pattern Overlay…", r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"##),
|
||||
style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow","depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##),
|
||||
style_cmd!("bevelEmboss", "Bevel & Emboss…", r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##),
|
||||
style_cmd!("satin", "Satin…", r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"invert":bool,"add":bool}"##),
|
||||
CommandSpec {
|
||||
id: "layer.layerStyle.clear",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use photocraft_raster::{Surface, from_rgba_into};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::photo_cmds::Stopwatch;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const LENS: &str = "filter.lensCorrection";
|
||||
|
|
@ -294,9 +295,9 @@ fn lens_correction(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let info = camera_info(&st.doc);
|
||||
let lc = lens_params(LENS, p, frame, info.as_ref())?;
|
||||
let stored = resolved_lens_params(p, &lc);
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = Stopwatch::start();
|
||||
let id = run_filter(s, LENS, "Lens Correction", stored.clone(), matches!(lc.edge, EdgeMode::Transparency), &|surf, canvas| lens::correct(surf, canvas, &lc))?;
|
||||
Ok(json!({"layer": id.0, "profile": lc.profile.as_ref().map(|p| p.name.clone()), "angle": lc.angle, "scale": lc.scale, "params": stored, "ms": t0.elapsed().as_secs_f64() * 1000.0}))
|
||||
Ok(json!({"layer": id.0, "profile": lc.profile.as_ref().map(|p| p.name.clone()), "angle": lc.angle, "scale": lc.scale, "params": stored, "ms": t0.ms()}))
|
||||
}
|
||||
|
||||
fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -304,7 +305,7 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let frame = st.doc.bounds();
|
||||
let info = camera_info(&st.doc);
|
||||
let wa = wide_params(WIDE, p, info.as_ref())?;
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = Stopwatch::start();
|
||||
let mesh = wideangle::solve(&wa, frame);
|
||||
let cam = wideangle::Camera::new(&wa, frame);
|
||||
let mut stored = serde_json::to_value(&wa).unwrap_or(Value::Null);
|
||||
|
|
@ -333,15 +334,15 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"curves": mesh.curves,
|
||||
"residual": mesh.residual,
|
||||
"params": stored,
|
||||
"ms": t0.elapsed().as_secs_f64() * 1000.0,
|
||||
"ms": t0.ms(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cr = raw_params(RAW, p)?;
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = Stopwatch::start();
|
||||
let id = run_filter(s, RAW, "Camera Raw Filter", p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
|
||||
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.elapsed().as_secs_f64() * 1000.0}))
|
||||
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()}))
|
||||
}
|
||||
|
||||
fn lens_batch(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,25 @@ use crate::commands::CommandSpec;
|
|||
use crate::file_cmds::{file_name, flattened, import, list_images, read_file};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Wall-clock timer for the `ms` fields of results (always 0 on the web, where
|
||||
/// `std::time::Instant` is unavailable).
|
||||
pub(crate) struct Stopwatch(#[cfg(not(target_arch = "wasm32"))] std::time::Instant);
|
||||
|
||||
impl Stopwatch {
|
||||
pub(crate) fn start() -> Self {
|
||||
Stopwatch(
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::time::Instant::now(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn ms(&self) -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return self.0.elapsed().as_secs_f64() * 1000.0;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest side used for feature registration.
|
||||
pub(crate) const REGISTER_SIDE: usize = 1200;
|
||||
|
||||
|
|
@ -373,7 +392,7 @@ pub(crate) fn seam_order(warped: &[Surface], reference: usize) -> Vec<usize> {
|
|||
|
||||
fn photomerge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.automate.photomerge";
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = Stopwatch::start();
|
||||
let layout_s = p.get("layout").and_then(Value::as_str).unwrap_or("auto");
|
||||
let layout = Layout::parse(layout_s).ok_or_else(|| bad(cmd, format!("unknown layout `{layout_s}` (auto|perspective|cylindrical|spherical|collage|reposition)")))?;
|
||||
let blend = p.get("blend").and_then(Value::as_bool).unwrap_or(true);
|
||||
|
|
@ -387,7 +406,7 @@ fn photomerge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let focal35 = p.get("focalLength").and_then(Value::as_f64).filter(|f| *f > 0.0).or_else(|| exif_focal(&srcs));
|
||||
let images: Vec<(&Surface, Rect)> = srcs.iter().map(|s| (&s.surf, Rect::new(0, 0, s.w as i32, s.h as i32))).collect();
|
||||
let al = register(&images, layout, None, geometric, focal35).ok_or_else(|| EngineError::Other("Photomerge couldn't find enough matching detail between the images".into()))?;
|
||||
let t_reg = t0.elapsed().as_secs_f64();
|
||||
let t_reg = t0.ms() / 1000.0;
|
||||
let placed: Vec<usize> = (0..srcs.len()).filter(|&i| al.placements[i].is_some()).collect();
|
||||
let failed: Vec<String> = (0..srcs.len()).filter(|&i| al.placements[i].is_none()).map(|i| srcs[i].name.clone()).collect();
|
||||
// Canvas: union of the placed images.
|
||||
|
|
@ -409,7 +428,7 @@ fn photomerge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let jobs: Vec<usize> = placed.clone();
|
||||
jobs.iter().map(|&i| warp_placed(&srcs[i].surf, Rect::new(0, 0, srcs[i].w as i32, srcs[i].h as i32), (0.0, 0.0), al.placements[i].as_ref().expect("placed"), offset, Interp::Bicubic)).collect()
|
||||
};
|
||||
let t_warp = t0.elapsed().as_secs_f64();
|
||||
let t_warp = t0.ms() / 1000.0;
|
||||
let mut doc = Document::new(format!("Untitled_Panorama{}", s.documents().len() + 1), Size::new(cw as u32, ch as u32), fmt.mode, fmt.sample);
|
||||
doc.icc_profile = icc;
|
||||
let mut info = json!({});
|
||||
|
|
@ -453,7 +472,7 @@ fn photomerge(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"height": ch as u32,
|
||||
"layers": n,
|
||||
"photometric": info,
|
||||
"ms": {"register": t_reg * 1000.0, "warp": (t_warp - t_reg) * 1000.0, "total": t0.elapsed().as_secs_f64() * 1000.0},
|
||||
"ms": {"register": t_reg * 1000.0, "warp": (t_warp - t_reg) * 1000.0, "total": t0.ms()},
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +552,7 @@ fn shift_rgba(px: &[[f32; 4]], w: usize, h: usize, dx: i32, dy: i32) -> Vec<[f32
|
|||
|
||||
fn merge_to_hdr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.automate.mergeToHdrPro";
|
||||
let t0 = std::time::Instant::now();
|
||||
let t0 = Stopwatch::start();
|
||||
let (srcs, _fmt, icc) = load_sources(s, p, cmd)?;
|
||||
if srcs.len() < 2 {
|
||||
return Err(bad(cmd, "Merge to HDR Pro needs two or more exposures"));
|
||||
|
|
@ -658,7 +677,7 @@ fn merge_to_hdr(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"ghostBase": merged.ghost_base,
|
||||
"ghostFraction": merged.ghost_fraction,
|
||||
"stops": merged.stops,
|
||||
"ms": t0.elapsed().as_secs_f64() * 1000.0,
|
||||
"ms": t0.ms(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -217,3 +217,43 @@ fn web_settings_from_params() {
|
|||
assert_eq!(WebSettings::from_params(&st.to_params(), "x").unwrap(), st, "{p} round-trips through params");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_show_the_optimised_pixels() {
|
||||
let s = session(8);
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let (wd, _, _) = web_document(&doc, &json!({}), &WebSettings::default()).unwrap();
|
||||
let buf = photocraft_compose::flatten(&wd);
|
||||
for f in ["jpeg", "gif", "png8", "png24", "wbmp"] {
|
||||
let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap();
|
||||
let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, true).unwrap();
|
||||
assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}");
|
||||
// Red square centre stays red (WBMP: dark).
|
||||
let i = (16 * 64 + 16) * 4;
|
||||
let px = &o.preview[i..i + 4];
|
||||
if f == "wbmp" {
|
||||
assert_eq!(px[0], 0);
|
||||
} else {
|
||||
assert!(px[0] > 200 && px[1] < 60 && px[2] < 60, "{f}: {px:?}");
|
||||
}
|
||||
let none = optimize(&buf.px, 64, wd.bounds(), &st, None, None, 72.0, false).unwrap();
|
||||
assert!(none.preview.is_empty());
|
||||
assert_eq!(none.bytes, o.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_preview_at_odd_sizes() {
|
||||
for (w, h, q) in [(640usize, 431usize, 90), (640, 430, 60), (333, 222, 30)] {
|
||||
let px: Vec<[f32; 4]> = (0..w * h).map(|i| [((i % w) as f32 / w as f32), 0.3, ((i / w) as f32 / h as f32), 1.0]).collect();
|
||||
let st = WebSettings::from_params(&json!({"format": "jpeg", "quality": q}), "x").unwrap();
|
||||
let o = optimize(&px, w, Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true).unwrap();
|
||||
for &(x, y) in &[(10usize, 10usize), (w - 10, h / 2), (w / 2, h - 5)] {
|
||||
let i = (y * w + x) * 4;
|
||||
let want = [(x as f32 / w as f32 * 255.0) as i32, 76, (y as f32 / h as f32 * 255.0) as i32];
|
||||
for c in 0..3 {
|
||||
assert!((i32::from(o.preview[i + c]) - want[c]).abs() < 12, "{w}x{h} at {x},{y}: {:?} vs {want:?}", &o.preview[i..i + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,3 +285,35 @@ fn vector_fields_roundtrip_and_default_when_absent() {
|
|||
assert!(old.paths.is_empty() && old.work_path.is_none());
|
||||
assert!(old.layers.iter().all(|l| l.vector_mask.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_restrictions_and_bevel_elements_roundtrip() {
|
||||
use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect};
|
||||
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8);
|
||||
doc.layers[0].excluded_channels = 0b101;
|
||||
let mut b = Bevel {
|
||||
enabled: true,
|
||||
style: photocraft_doc::BevelStyle::Emboss,
|
||||
technique: photocraft_doc::BevelTechnique::ChiselHard,
|
||||
depth: 1.2,
|
||||
up: false,
|
||||
size: 9.0,
|
||||
soften: 1.0,
|
||||
angle: 30.0,
|
||||
altitude: 40.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: Contour::Linear,
|
||||
highlight: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Screen, 0.7),
|
||||
highlight_color: photocraft_color::Color::WHITE,
|
||||
shadow: photocraft_doc::FxCommon::new(photocraft_color::BlendMode::Multiply, 0.6),
|
||||
shadow_color: photocraft_color::Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
};
|
||||
b.contour = Some(BevelContour { contour: Contour::Linear, range: 0.3, anti_alias: true });
|
||||
b.texture = Some(BevelTexture { name: "p".into(), id: "i".into(), scale: 0.5, depth: -1.5, invert: true, link: false, phase: (1.0, 2.0) });
|
||||
doc.layers[0].effects.items.push(Effect::BevelEmboss(Bevel { ..b }));
|
||||
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert_eq!(back.layers[0].excluded_channels, 0b101);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,6 +329,13 @@ pub(crate) fn program(e: &Effect, light: &GlobalLight, vector_shape: bool) -> Ma
|
|||
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0));
|
||||
if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
|
||||
};
|
||||
if let Some(c) = &bv.contour {
|
||||
// Contour element: the height through the contour over its range.
|
||||
let lut = photocraft_compose::effects::ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect());
|
||||
let mut st = stage(Kernel::MFinish, Some(h), None, [0.0, 0.0, 1.0, 0.0], 0);
|
||||
st.lut = Some(Arc::new(lut));
|
||||
h = b.push(st);
|
||||
}
|
||||
if bv.soften >= 1.0 {
|
||||
h = b.conv(h, photocraft_compose::effects::tent_kernel(bv.soften));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
//! Vector masks (rasterised once per mask state into a combined mask texture), layers clipped to
|
||||
//! pass-through groups, stroked shapes with clipped layers (fill and stroke split once per shape
|
||||
//! state), pattern fills and artboards are planned like everything else. What remains
|
||||
//! (Multichannel documents, documents or effect regions larger than the device's texture limit)
|
||||
//! returns [`Unsupported`]; callers fall back to the CPU compositor.
|
||||
//! (Multichannel documents, bevels with a texture, documents or effect regions larger than the
|
||||
//! device's texture limit) returns [`Unsupported`]; callers fall back to the CPU compositor.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bounds;
|
||||
|
|
|
|||
|
|
@ -756,6 +756,9 @@ impl<'a> Planner<'a> {
|
|||
/// (atop = true: effects over the base treated as opaque, keeping the base's alpha).
|
||||
/// Consumes `backdrop`.
|
||||
fn effects(&mut self, layer: &'a Layer, clipped: &[&'a Layer], backdrop: Slot, atop: bool) -> Result<Slot, Unsupported> {
|
||||
if layer.effects.items.iter().any(|e| matches!(e, Effect::BevelEmboss(b) if b.enabled && b.texture.is_some())) {
|
||||
return Err(Unsupported(format!("bevel texture on `{}` (rendered on the CPU)", layer.name)));
|
||||
}
|
||||
let canvas = self.cx.canvas;
|
||||
let region = bounds::effect_region(layer, canvas);
|
||||
let sb = bounds::layer_bounds(layer, canvas);
|
||||
|
|
|
|||
|
|
@ -407,6 +407,8 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel {
|
|||
highlight_color: Color::rgb(1.0, 1.0, 0.9),
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.7),
|
||||
shadow_color: Color::rgb(0.1, 0.0, 0.2),
|
||||
contour: None,
|
||||
texture: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -462,6 +464,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
|||
("bevel pillow chisel hard", vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })]),
|
||||
("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]),
|
||||
("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]),
|
||||
("bevel contour", vec![Effect::BevelEmboss(Bevel { contour: Some(photocraft_doc::BevelContour { contour: contour(), range: 0.6, anti_alias: false }), ..bevel(BevelStyle::Emboss, true, 9.0, 1.0) })]),
|
||||
("bevel contour own light", vec![Effect::BevelEmboss(bevel_contour)]),
|
||||
("satin", vec![Effect::Satin(satin)]),
|
||||
("satin inverted contour", vec![Effect::Satin(satin_inv)]),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use photocraft_color::{BlendMode, Color};
|
||||
use photocraft_doc::adjust::CurvePoint;
|
||||
use photocraft_doc::{
|
||||
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow,
|
||||
Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin, Shadow,
|
||||
StrokeFx, StrokePosition,
|
||||
};
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
|
||||
|
|
@ -210,6 +210,12 @@ fn parse_bevel(d: &Descriptor) -> Bevel {
|
|||
highlight_color: get_desc(d, "hglC").and_then(color_from_desc).unwrap_or(Color::WHITE),
|
||||
shadow: FxCommon { enabled: true, blend: blend_of(d, "sdwM", BlendMode::Multiply), opacity: pct(d, "sdwO", 0.75) },
|
||||
shadow_color: color(d, "sdwC"),
|
||||
contour: bool_of(d, "useShape").then(|| BevelContour { contour: contour(d, "MpgS"), range: pct(d, "Inpr", 0.5), anti_alias: bool_of(d, "AntA") }),
|
||||
texture: bool_of(d, "useTexture").then(|| {
|
||||
let (name, id) = pattern_ref(d);
|
||||
let (_, link, phase) = pattern_placement(d);
|
||||
BevelTexture { name, id, scale: pct(d, "Scl ", 1.0), depth: pct(d, "textureDepth", 1.0), invert: bool_of(d, "InvT"), link, phase }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +452,23 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) {
|
|||
.with("bvlD", Value::Enumerated { type_id: Id::new("BESs"), value: Id::new(if b.up { "In " } else { "Out " }) })
|
||||
.with("TrnS", contour_value(&b.gloss_contour))
|
||||
.with("Sftn", unit(b"#Pxl", b.soften));
|
||||
let d = match &b.contour {
|
||||
Some(c) => d.with("useShape", Value::Boolean(true)).with("MpgS", contour_value(&c.contour)).with("AntA", Value::Boolean(c.anti_alias)).with("Inpr", unit(b"#Prc", c.range * 100.0)),
|
||||
None => d.with("useShape", Value::Boolean(false)),
|
||||
};
|
||||
let d = match &b.texture {
|
||||
Some(t) => with_pattern_placement(
|
||||
d.with("useTexture", Value::Boolean(true))
|
||||
.with("InvT", Value::Boolean(t.invert))
|
||||
.with("Scl ", unit(b"#Prc", t.scale * 100.0))
|
||||
.with("textureDepth", unit(b"#Prc", t.depth * 100.0))
|
||||
.with("Ptrn", Value::Descriptor(pattern_ref_desc(&t.name, &t.id))),
|
||||
0.0,
|
||||
t.link,
|
||||
t.phase,
|
||||
),
|
||||
None => d.with("useTexture", Value::Boolean(false)),
|
||||
};
|
||||
("ebbl", d)
|
||||
}
|
||||
}
|
||||
|
|
@ -622,6 +645,8 @@ mod tests {
|
|||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.6),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: Some(BevelContour { contour: Contour::Linear, range: 0.7, anti_alias: true }),
|
||||
texture: Some(BevelTexture { name: "Bubbles".into(), id: "abc".into(), scale: 0.5, depth: -2.0, invert: true, link: false, phase: (3.0, 1.0) }),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,3 +400,26 @@ fn link_groups_and_effects_reference_round_trip() {
|
|||
let fnone = PsdFile::from_bytes(&export(&none, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
assert!(res(&fnone).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_restrictions_round_trip_as_brst() {
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
let mut d = Document::new("c", Size::new(8, 8), ColorMode::Rgb, SampleType::U8);
|
||||
let mut a = Layer::raster("a", d.pixel_format());
|
||||
a.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
a.excluded_channels = 0b110;
|
||||
let b = Layer::raster("b", d.pixel_format());
|
||||
d.layers = vec![a, b];
|
||||
let f = document_to_psd(&d);
|
||||
let rec = f.layers().iter().find(|r| r.name() == "a").unwrap();
|
||||
assert_eq!(rec.block(b"brst").unwrap().data, vec![0, 0, 0, 1, 0, 0, 0, 2]);
|
||||
assert!(f.layers().iter().find(|r| r.name() == "b").unwrap().block(b"brst").is_none());
|
||||
let back = roundtrip(&d);
|
||||
assert_eq!(back.layers[0].excluded_channels, 0b110);
|
||||
assert_eq!(back.layers[1].excluded_channels, 0);
|
||||
// Cleared restrictions drop the preserved block.
|
||||
let mut cleared = back.clone();
|
||||
cleared.layers[0].excluded_channels = 0;
|
||||
let f = document_to_psd(&cleared);
|
||||
assert!(f.layers().iter().find(|r| r.name() == "a").unwrap().block(b"brst").is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ pub fn in_category(c: char, category: &str) -> bool {
|
|||
"Greek" => (0x370..=0x3FF).contains(&u) || (0x1F00..=0x1FFF).contains(&u),
|
||||
"Cyrillic" => (0x400..=0x52F).contains(&u),
|
||||
"Punctuation" => {
|
||||
c.is_ascii_punctuation() && !matches!(c, '$' | '+' | '<' | '=' | '>' | '^' | '`' | '|' | '~')
|
||||
c.is_ascii_punctuation()
|
||||
&& !matches!(c, '$' | '+' | '<' | '=' | '>' | '^' | '`' | '|' | '~')
|
||||
|| matches!(u, 0xA1 | 0xA7 | 0xAB | 0xB6 | 0xB7 | 0xBB | 0xBF)
|
||||
|| (0x2010..=0x2027).contains(&u)
|
||||
|| (0x2030..=0x205E).contains(&u)
|
||||
|
|
@ -48,20 +49,60 @@ pub fn in_category(c: char, category: &str) -> bool {
|
|||
"Numbers" => c.is_numeric() || (0x2150..=0x218F).contains(&u),
|
||||
"Currency" => matches!(c, '$' | '¢' | '£' | '¤' | '¥') || (0x20A0..=0x20CF).contains(&u),
|
||||
"Symbols" => {
|
||||
matches!(c, '©' | '®' | '°' | '¦' | '¨' | '¯' | '´' | '¸' | '™' | '℠' | '№' | '℮' | '^' | '`' | '|' | '~')
|
||||
|| (0x2100..=0x214F).contains(&u)
|
||||
matches!(
|
||||
c,
|
||||
'©' | '®'
|
||||
| '°'
|
||||
| '¦'
|
||||
| '¨'
|
||||
| '¯'
|
||||
| '´'
|
||||
| '¸'
|
||||
| '™'
|
||||
| '℠'
|
||||
| '№'
|
||||
| '℮'
|
||||
| '^'
|
||||
| '`'
|
||||
| '|'
|
||||
| '~'
|
||||
) || (0x2100..=0x214F).contains(&u)
|
||||
|| (0x2300..=0x23FF).contains(&u)
|
||||
|| (0x2500..=0x25FF).contains(&u)
|
||||
}
|
||||
"Math Symbols" => {
|
||||
matches!(c, '+' | '<' | '=' | '>' | '±' | '×' | '÷' | '¬' | 'µ' | '¹' | '²' | '³' | '¼' | '½' | '¾')
|
||||
|| (0x2070..=0x209F).contains(&u)
|
||||
matches!(
|
||||
c,
|
||||
'+' | '<'
|
||||
| '='
|
||||
| '>'
|
||||
| '±'
|
||||
| '×'
|
||||
| '÷'
|
||||
| '¬'
|
||||
| 'µ'
|
||||
| '¹'
|
||||
| '²'
|
||||
| '³'
|
||||
| '¼'
|
||||
| '½'
|
||||
| '¾'
|
||||
) || (0x2070..=0x209F).contains(&u)
|
||||
|| (0x2200..=0x22FF).contains(&u)
|
||||
|| (0x27C0..=0x27EF).contains(&u)
|
||||
|| (0x2980..=0x2AFF).contains(&u)
|
||||
}
|
||||
"Arrows" => (0x2190..=0x21FF).contains(&u) || (0x27F0..=0x27FF).contains(&u) || (0x2900..=0x297F).contains(&u) || (0x2B00..=0x2BFF).contains(&u),
|
||||
"Ornaments" => (0x2600..=0x27BF).contains(&u) || (0x1F300..=0x1FAFF).contains(&u) || (0xE000..=0xF8FF).contains(&u),
|
||||
"Arrows" => {
|
||||
(0x2190..=0x21FF).contains(&u)
|
||||
|| (0x27F0..=0x27FF).contains(&u)
|
||||
|| (0x2900..=0x297F).contains(&u)
|
||||
|| (0x2B00..=0x2BFF).contains(&u)
|
||||
}
|
||||
"Ornaments" => {
|
||||
(0x2600..=0x27BF).contains(&u)
|
||||
|| (0x1F300..=0x1FAFF).contains(&u)
|
||||
|| (0xE000..=0xF8FF).contains(&u)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +117,8 @@ impl FontDb {
|
|||
let fonts = info.fonts();
|
||||
let Some(font) = fonts.iter().min_by_key(|f| {
|
||||
let it = !matches!(f.style(), parley::fontique::FontStyle::Normal);
|
||||
(u32::from(it != italic) * 10_000) + (f.weight().value() - f32::from(weight)).abs() as u32
|
||||
(u32::from(it != italic) * 10_000)
|
||||
+ (f.weight().value() - f32::from(weight)).abs() as u32
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
|
@ -91,7 +133,12 @@ impl FontDb {
|
|||
.mappings()
|
||||
.filter(|(_, g)| g.to_u32() != 0)
|
||||
.filter_map(|(u, _)| char::from_u32(u))
|
||||
.filter(|c| *c == ' ' || !(c.is_control() || c.is_whitespace() || ('\u{FE00}'..='\u{FE0F}').contains(c)))
|
||||
.filter(|c| {
|
||||
*c == ' '
|
||||
|| !(c.is_control()
|
||||
|| c.is_whitespace()
|
||||
|| ('\u{FE00}'..='\u{FE0F}').contains(c))
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
|
|
@ -101,7 +148,12 @@ impl FontDb {
|
|||
|
||||
/// An 8-bit coverage preview of one character in a `px`-pixel em box, centred horizontally on
|
||||
/// the baseline at 80 % of the box: (width = height = `px`, alpha bytes).
|
||||
pub fn preview(engine: &mut crate::TextEngine, style: &CharStyle, c: char, px: u32) -> (u32, Vec<u8>) {
|
||||
pub fn preview(
|
||||
engine: &mut crate::TextEngine,
|
||||
style: &CharStyle,
|
||||
c: char,
|
||||
px: u32,
|
||||
) -> (u32, Vec<u8>) {
|
||||
let px = px.clamp(4, 512);
|
||||
let text = c.to_string();
|
||||
let mut st = style.clone();
|
||||
|
|
@ -110,15 +162,31 @@ pub fn preview(engine: &mut crate::TextEngine, style: &CharStyle, c: char, px: u
|
|||
st.strikethrough = false;
|
||||
st.tracking = 0.0;
|
||||
st.baseline_shift_pt = 0.0;
|
||||
let layer = TextLayer { runs: vec![TextRun { len: text.len(), style: st }], text, ..Default::default() };
|
||||
let layer = TextLayer {
|
||||
runs: vec![TextRun {
|
||||
len: text.len(),
|
||||
style: st,
|
||||
}],
|
||||
text,
|
||||
..Default::default()
|
||||
};
|
||||
let layout = engine.layout(&layer, 72.0);
|
||||
let w = layout.bounds().map_or(0.0, |b| b[2] - b[0]);
|
||||
let x0 = layout.bounds().map_or(0.0, |b| b[0]);
|
||||
let tx = (px as f32 - w) / 2.0 - x0;
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U8, true);
|
||||
let r = crate::render::rasterize_warped(&layout, &Affine::translate(f64::from(tx), f64::from(px as f32 * 0.78)), fmt, photocraft_doc::text::AntiAlias::Smooth, None);
|
||||
let r = crate::render::rasterize_warped(
|
||||
&layout,
|
||||
&Affine::translate(f64::from(tx), f64::from(px as f32 * 0.78)),
|
||||
fmt,
|
||||
photocraft_doc::text::AntiAlias::Smooth,
|
||||
None,
|
||||
);
|
||||
let mut rgba = vec![[0u8; 4]; (px * px) as usize];
|
||||
r.surface.read_rgba8_into(photocraft_geom::Rect::new(0, 0, px as i32, px as i32), &mut rgba);
|
||||
r.surface.read_rgba8_into(
|
||||
photocraft_geom::Rect::new(0, 0, px as i32, px as i32),
|
||||
&mut rgba,
|
||||
);
|
||||
(px, rgba.into_iter().map(|p| p[3]).collect())
|
||||
}
|
||||
|
||||
|
|
@ -134,16 +202,27 @@ mod tests {
|
|||
assert!(cm.contains(&'A') && cm.contains(&'€') && cm.contains(&' '));
|
||||
assert!(!cm.contains(&'\n'));
|
||||
assert!(db.charmap("No Such Family", 400, false).is_empty());
|
||||
let latin: Vec<char> = cm.iter().copied().filter(|c| in_category(*c, "Basic Latin")).collect();
|
||||
let latin: Vec<char> = cm
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| in_category(*c, "Basic Latin"))
|
||||
.collect();
|
||||
assert_eq!(latin.len(), 95);
|
||||
assert!(in_category('€', "Currency") && in_category('—', "Punctuation") && in_category('→', "Arrows"));
|
||||
assert!(
|
||||
in_category('€', "Currency")
|
||||
&& in_category('—', "Punctuation")
|
||||
&& in_category('→', "Arrows")
|
||||
);
|
||||
assert!(!in_category('A', "Punctuation") && in_category('A', "Entire Font"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_draws_ink() {
|
||||
let mut eng = crate::TextEngine::new();
|
||||
let st = CharStyle { font_family: crate::fonts::DEFAULT_FAMILY.into(), ..Default::default() };
|
||||
let st = CharStyle {
|
||||
font_family: crate::fonts::DEFAULT_FAMILY.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let (n, a) = preview(&mut eng, &st, 'W', 32);
|
||||
assert_eq!((n, a.len()), (32, 32 * 32));
|
||||
let ink: u32 = a.iter().map(|&v| u32::from(v)).sum();
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ use photocraft_doc::text::{CharStyle, ParagraphStyle};
|
|||
use photocraft_doc::{TextLayer, TextStyles};
|
||||
|
||||
use crate::engine_data::Value as E;
|
||||
use crate::psd::{char_style, engine_data, para_style, paragraph_properties, parse_tysh, postscript_for, style_sheet_data, write_tysh};
|
||||
use crate::psd::{
|
||||
char_style, engine_data, para_style, paragraph_properties, parse_tysh, postscript_for,
|
||||
style_sheet_data, write_tysh,
|
||||
};
|
||||
|
||||
/// Style sheets read from one type layer.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
|
@ -39,16 +42,26 @@ fn k_of(dpi: f32) -> f32 {
|
|||
}
|
||||
|
||||
fn names(set: Option<&E>) -> Vec<String> {
|
||||
set.and_then(E::as_array).map(|a| a.iter().map(|s| s.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()).unwrap_or_default()
|
||||
set.and_then(E::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|s| s.get("Name").and_then(E::as_str).unwrap_or("").to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Index a run's sheet dictionary refers to (`Parent` index, else `Name`), skipping `normal`.
|
||||
fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option<usize> {
|
||||
let s = sheet?;
|
||||
let i = s.get("Parent").and_then(E::as_i64).map(|i| i as usize).or_else(|| {
|
||||
let n = s.get("Name").and_then(E::as_str)?;
|
||||
set_names.iter().position(|x| x == n)
|
||||
})?;
|
||||
let i = s
|
||||
.get("Parent")
|
||||
.and_then(E::as_i64)
|
||||
.map(|i| i as usize)
|
||||
.or_else(|| {
|
||||
let n = s.get("Name").and_then(E::as_str)?;
|
||||
set_names.iter().position(|x| x == n)
|
||||
})?;
|
||||
(i != normal && i < set_names.len()).then_some(i)
|
||||
}
|
||||
|
||||
|
|
@ -57,25 +70,44 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
|||
let t = parse_tysh(tysh)?;
|
||||
let e = engine_data(&t.text)?;
|
||||
let k = k_of(dpi);
|
||||
let res = e.get("ResourceDict").or_else(|| e.get("DocumentResources"))?;
|
||||
let res = e
|
||||
.get("ResourceDict")
|
||||
.or_else(|| e.get("DocumentResources"))?;
|
||||
let fonts: Vec<String> = names(res.get("FontSet"));
|
||||
let sset = res.get("StyleSheetSet").and_then(E::as_array).unwrap_or(&[]);
|
||||
let pset = res.get("ParagraphSheetSet").and_then(E::as_array).unwrap_or(&[]);
|
||||
let normal_c = res.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let normal_p = res.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let sset = res
|
||||
.get("StyleSheetSet")
|
||||
.and_then(E::as_array)
|
||||
.unwrap_or(&[]);
|
||||
let pset = res
|
||||
.get("ParagraphSheetSet")
|
||||
.and_then(E::as_array)
|
||||
.unwrap_or(&[]);
|
||||
let normal_c = res
|
||||
.get("TheNormalStyleSheet")
|
||||
.and_then(E::as_i64)
|
||||
.unwrap_or(0) as usize;
|
||||
let normal_p = res
|
||||
.get("TheNormalParagraphSheet")
|
||||
.and_then(E::as_i64)
|
||||
.unwrap_or(0) as usize;
|
||||
let empty = E::dict();
|
||||
let base_data = sset.get(normal_c).and_then(|s| s.get("StyleSheetData"));
|
||||
let normal_char = char_style(None, base_data.unwrap_or(&empty), &fonts, k);
|
||||
let base_props = pset.get(normal_p).and_then(|s| s.get("Properties"));
|
||||
let normal_para = para_style(None, base_props.unwrap_or(&empty), k);
|
||||
let mut out = PsdStyleSheets { normal_char, normal_para, ..Default::default() };
|
||||
let mut out = PsdStyleSheets {
|
||||
normal_char,
|
||||
normal_para,
|
||||
..Default::default()
|
||||
};
|
||||
for (i, s) in sset.iter().enumerate() {
|
||||
if i == normal_c {
|
||||
continue;
|
||||
}
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let data = s.get("StyleSheetData").unwrap_or(&empty);
|
||||
out.char_sheets.push((i, name, char_style(base_data, data, &fonts, k)));
|
||||
out.char_sheets
|
||||
.push((i, name, char_style(base_data, data, &fonts, k)));
|
||||
}
|
||||
for (i, s) in pset.iter().enumerate() {
|
||||
if i == normal_p {
|
||||
|
|
@ -83,17 +115,41 @@ pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
|||
}
|
||||
let name = s.get("Name").and_then(E::as_str).unwrap_or("").to_string();
|
||||
let props = s.get("Properties").unwrap_or(&empty);
|
||||
let dss = s.get("DefaultStyleSheet").and_then(E::as_i64).map(|i| i as usize).filter(|i| *i != normal_c);
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| char_style(base_data, d.get("StyleSheetData").unwrap_or(&empty), &fonts, k));
|
||||
out.para_sheets.push((i, name, para_style(base_props, props, k), cs));
|
||||
let dss = s
|
||||
.get("DefaultStyleSheet")
|
||||
.and_then(E::as_i64)
|
||||
.map(|i| i as usize)
|
||||
.filter(|i| *i != normal_c);
|
||||
let cs = dss.and_then(|d| sset.get(d)).map(|d| {
|
||||
char_style(
|
||||
base_data,
|
||||
d.get("StyleSheetData").unwrap_or(&empty),
|
||||
&fonts,
|
||||
k,
|
||||
)
|
||||
});
|
||||
out.para_sheets
|
||||
.push((i, name, para_style(base_props, props, k), cs));
|
||||
}
|
||||
let snames = names(res.get("StyleSheetSet"));
|
||||
let pnames = names(res.get("ParagraphSheetSet"));
|
||||
if let Some(a) = e.path(&["EngineDict", "StyleRun", "RunArray"]).and_then(E::as_array) {
|
||||
out.run_char = a.iter().map(|r| reference(r.get("StyleSheet"), &snames, normal_c)).collect();
|
||||
if let Some(a) = e
|
||||
.path(&["EngineDict", "StyleRun", "RunArray"])
|
||||
.and_then(E::as_array)
|
||||
{
|
||||
out.run_char = a
|
||||
.iter()
|
||||
.map(|r| reference(r.get("StyleSheet"), &snames, normal_c))
|
||||
.collect();
|
||||
}
|
||||
if let Some(a) = e.path(&["EngineDict", "ParagraphRun", "RunArray"]).and_then(E::as_array) {
|
||||
out.run_para = a.iter().map(|r| reference(r.get("ParagraphSheet"), &pnames, normal_p)).collect();
|
||||
if let Some(a) = e
|
||||
.path(&["EngineDict", "ParagraphRun", "RunArray"])
|
||||
.and_then(E::as_array)
|
||||
{
|
||||
out.run_para = a
|
||||
.iter()
|
||||
.map(|r| reference(r.get("ParagraphSheet"), &pnames, normal_p))
|
||||
.collect();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
|
@ -106,25 +162,48 @@ fn remove_key(d: &mut E, key: &str) {
|
|||
|
||||
/// Whether a `TySh` block carries named sheets beyond the normal ones.
|
||||
pub fn has_named_sheets(tysh: &[u8]) -> bool {
|
||||
let Some(e) = parse_tysh(tysh).and_then(|t| engine_data(&t.text)) else { return false };
|
||||
let Some(res) = e.get("ResourceDict") else { return false };
|
||||
let Some(e) = parse_tysh(tysh).and_then(|t| engine_data(&t.text)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(res) = e.get("ResourceDict") else {
|
||||
return false;
|
||||
};
|
||||
let n = |k: &str| res.get(k).and_then(E::as_array).map_or(0, <[E]>::len);
|
||||
n("StyleSheetSet") > 1 || n("ParagraphSheetSet") > 1
|
||||
}
|
||||
|
||||
/// Rewrites the style sheets of a `TySh` block from the document's styles and the layer's run
|
||||
/// references. The normal sheets are kept; other sheets are replaced by `styles`.
|
||||
pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, dpi: f32) -> Option<Vec<u8>> {
|
||||
pub fn write_style_sheets(
|
||||
tysh: &[u8],
|
||||
layer: &TextLayer,
|
||||
styles: &TextStyles,
|
||||
dpi: f32,
|
||||
) -> Option<Vec<u8>> {
|
||||
let mut t = parse_tysh(tysh)?;
|
||||
let mut e = engine_data(&t.text)?;
|
||||
let k = k_of(dpi);
|
||||
for key in ["ResourceDict", "DocumentResources"] {
|
||||
let Some(rd) = e.get_mut(key) else { continue };
|
||||
let mut fonts = names(rd.get("FontSet"));
|
||||
let normal_c = rd.get("TheNormalStyleSheet").and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let normal_p = rd.get("TheNormalParagraphSheet").and_then(E::as_i64).unwrap_or(0) as usize;
|
||||
let keep_c = rd.get("StyleSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_c).or(a.first())).cloned();
|
||||
let keep_p = rd.get("ParagraphSheetSet").and_then(E::as_array).and_then(|a| a.get(normal_p).or(a.first())).cloned();
|
||||
let normal_c = rd
|
||||
.get("TheNormalStyleSheet")
|
||||
.and_then(E::as_i64)
|
||||
.unwrap_or(0) as usize;
|
||||
let normal_p = rd
|
||||
.get("TheNormalParagraphSheet")
|
||||
.and_then(E::as_i64)
|
||||
.unwrap_or(0) as usize;
|
||||
let keep_c = rd
|
||||
.get("StyleSheetSet")
|
||||
.and_then(E::as_array)
|
||||
.and_then(|a| a.get(normal_c).or(a.first()))
|
||||
.cloned();
|
||||
let keep_p = rd
|
||||
.get("ParagraphSheetSet")
|
||||
.and_then(E::as_array)
|
||||
.and_then(|a| a.get(normal_p).or(a.first()))
|
||||
.cloned();
|
||||
let mut font_index = |s: &CharStyle| {
|
||||
let ps = postscript_for(s);
|
||||
match fonts.iter().position(|f| *f == ps) {
|
||||
|
|
@ -139,23 +218,45 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
|||
for d in &styles.character {
|
||||
let st = styles.resolve_char(None, Some(d.id), crate::fonts::DEFAULT_FAMILY);
|
||||
let fi = font_index(&st);
|
||||
sset.push(E::Dict(vec![("Name".into(), E::String(d.name.clone())), ("Parent".into(), E::Int(0)), ("StyleSheetData".into(), style_sheet_data(&st, fi, k))]));
|
||||
sset.push(E::Dict(vec![
|
||||
("Name".into(), E::String(d.name.clone())),
|
||||
("Parent".into(), E::Int(0)),
|
||||
("StyleSheetData".into(), style_sheet_data(&st, fi, k)),
|
||||
]));
|
||||
}
|
||||
let mut pset: Vec<E> = keep_p.into_iter().collect();
|
||||
for d in &styles.paragraph {
|
||||
let p = styles.resolve_para(Some(d.id));
|
||||
pset.push(E::Dict(vec![("Name".into(), E::String(d.name.clone())), ("DefaultStyleSheet".into(), E::Int(0)), ("Parent".into(), E::Int(0)), ("Properties".into(), paragraph_properties(&p, k))]));
|
||||
pset.push(E::Dict(vec![
|
||||
("Name".into(), E::String(d.name.clone())),
|
||||
("DefaultStyleSheet".into(), E::Int(0)),
|
||||
("Parent".into(), E::Int(0)),
|
||||
("Properties".into(), paragraph_properties(&p, k)),
|
||||
]));
|
||||
}
|
||||
rd.set("StyleSheetSet", E::Array(sset));
|
||||
rd.set("ParagraphSheetSet", E::Array(pset));
|
||||
rd.set("TheNormalStyleSheet", E::Int(0));
|
||||
rd.set("TheNormalParagraphSheet", E::Int(0));
|
||||
rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()));
|
||||
rd.set(
|
||||
"FontSet",
|
||||
E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect()),
|
||||
);
|
||||
}
|
||||
let char_ix = |id: Option<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let para_ix = |id: Option<u32>| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let char_ix = |id: Option<u32>| {
|
||||
id.and_then(|id| styles.character.iter().position(|d| d.id == id))
|
||||
.map(|i| i as i64 + 1)
|
||||
};
|
||||
let para_ix = |id: Option<u32>| {
|
||||
id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id))
|
||||
.map(|i| i as i64 + 1)
|
||||
};
|
||||
let runs = layer.char_runs();
|
||||
if let Some(E::Array(a)) = e.get_mut("EngineDict").and_then(|d| d.get_mut("StyleRun")).and_then(|r| r.get_mut("RunArray")) {
|
||||
if let Some(E::Array(a)) = e
|
||||
.get_mut("EngineDict")
|
||||
.and_then(|d| d.get_mut("StyleRun"))
|
||||
.and_then(|r| r.get_mut("RunArray"))
|
||||
{
|
||||
for (i, r) in a.iter_mut().enumerate() {
|
||||
if let Some(sheet) = r.get_mut("StyleSheet") {
|
||||
remove_key(sheet, "Parent");
|
||||
|
|
@ -167,7 +268,11 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d
|
|||
}
|
||||
}
|
||||
let paras = layer.paragraph_runs();
|
||||
if let Some(E::Array(a)) = e.get_mut("EngineDict").and_then(|d| d.get_mut("ParagraphRun")).and_then(|r| r.get_mut("RunArray")) {
|
||||
if let Some(E::Array(a)) = e
|
||||
.get_mut("EngineDict")
|
||||
.and_then(|d| d.get_mut("ParagraphRun"))
|
||||
.and_then(|r| r.get_mut("RunArray"))
|
||||
{
|
||||
for (i, r) in a.iter_mut().enumerate() {
|
||||
if let Some(sheet) = r.get_mut("ParagraphSheet") {
|
||||
remove_key(sheet, "Parent");
|
||||
|
|
@ -195,14 +300,51 @@ mod tests {
|
|||
#[test]
|
||||
fn named_sheets_round_trip() {
|
||||
let mut styles = TextStyles::default();
|
||||
styles.character.push(CharacterStyleDef { id: 4, name: "Emphasis".into(), attrs: diff_attrs(&CharStyle { size_pt: 30.0, underline: true, ..Default::default() }, &CharStyle::default()) });
|
||||
styles.paragraph.push(ParagraphStyleDef { id: 2, name: "Centered".into(), para_attrs: diff_attrs(&ParagraphStyle { align: TextAlign::Center, ..Default::default() }, &ParagraphStyle::default()), ..Default::default() });
|
||||
let base = CharStyle { font_family: crate::fonts::DEFAULT_FAMILY.into(), ..Default::default() };
|
||||
styles.character.push(CharacterStyleDef {
|
||||
id: 4,
|
||||
name: "Emphasis".into(),
|
||||
attrs: diff_attrs(
|
||||
&CharStyle {
|
||||
size_pt: 30.0,
|
||||
underline: true,
|
||||
..Default::default()
|
||||
},
|
||||
&CharStyle::default(),
|
||||
),
|
||||
});
|
||||
styles.paragraph.push(ParagraphStyleDef {
|
||||
id: 2,
|
||||
name: "Centered".into(),
|
||||
para_attrs: diff_attrs(
|
||||
&ParagraphStyle {
|
||||
align: TextAlign::Center,
|
||||
..Default::default()
|
||||
},
|
||||
&ParagraphStyle::default(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
let base = CharStyle {
|
||||
font_family: crate::fonts::DEFAULT_FAMILY.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let emph = styles.resolve_char(Some(2), Some(4), crate::fonts::DEFAULT_FAMILY);
|
||||
let mut layer = TextLayer {
|
||||
text: "plain bold".into(),
|
||||
runs: vec![TextRun { len: 6, style: base.clone() }, TextRun { len: 4, style: emph }],
|
||||
paragraphs: vec![photocraft_doc::text::ParagraphRun { len: 10, style: styles.resolve_para(Some(2)) }],
|
||||
runs: vec![
|
||||
TextRun {
|
||||
len: 6,
|
||||
style: base.clone(),
|
||||
},
|
||||
TextRun {
|
||||
len: 4,
|
||||
style: emph,
|
||||
},
|
||||
],
|
||||
paragraphs: vec![photocraft_doc::text::ParagraphRun {
|
||||
len: 10,
|
||||
style: styles.resolve_para(Some(2)),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
layer.sync_summary();
|
||||
|
|
@ -213,7 +355,10 @@ mod tests {
|
|||
let r = read_style_sheets(&out, 72.0).unwrap();
|
||||
assert_eq!(r.char_sheets.len(), 1);
|
||||
assert_eq!(r.char_sheets[0].1, "Emphasis");
|
||||
assert_eq!((r.char_sheets[0].2.size_pt, r.char_sheets[0].2.underline), (30.0, true));
|
||||
assert_eq!(
|
||||
(r.char_sheets[0].2.size_pt, r.char_sheets[0].2.underline),
|
||||
(30.0, true)
|
||||
);
|
||||
assert_eq!(r.para_sheets[0].1, "Centered");
|
||||
assert_eq!(r.para_sheets[0].2.align, TextAlign::Center);
|
||||
assert_eq!(r.run_char, vec![None, Some(1)]);
|
||||
|
|
@ -225,7 +370,13 @@ mod tests {
|
|||
// No styles: the sets shrink back to the normal sheets.
|
||||
let plain = write_style_sheets(&out, &layer, &TextStyles::default(), 72.0).unwrap();
|
||||
assert!(!has_named_sheets(&plain));
|
||||
assert!(read_style_sheets(&plain, 72.0).unwrap().run_char.iter().all(Option::is_none));
|
||||
assert!(
|
||||
read_style_sheets(&plain, 72.0)
|
||||
.unwrap()
|
||||
.run_char
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -233,6 +384,14 @@ mod tests {
|
|||
assert!(read_style_sheets(&[], 72.0).is_none());
|
||||
assert!(read_style_sheets(&[0u8; 60], 72.0).is_none());
|
||||
assert!(!has_named_sheets(b"junk"));
|
||||
assert!(write_style_sheets(&[1, 2, 3], &TextLayer::default(), &TextStyles::default(), 72.0).is_none());
|
||||
assert!(
|
||||
write_style_sheets(
|
||||
&[1, 2, 3],
|
||||
&TextLayer::default(),
|
||||
&TextStyles::default(),
|
||||
72.0
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@ impl Dictionary {
|
|||
continue;
|
||||
}
|
||||
words.insert(w.to_string(), level);
|
||||
by_lower.entry(w.to_lowercase()).or_default().push(w.to_string());
|
||||
by_lower
|
||||
.entry(w.to_lowercase())
|
||||
.or_default()
|
||||
.push(w.to_string());
|
||||
}
|
||||
let mut by_len: Vec<Vec<(String, Vec<char>, u8)>> = Vec::new();
|
||||
for (lw, forms) in &by_lower {
|
||||
|
|
@ -68,13 +71,22 @@ impl Dictionary {
|
|||
if by_len.len() <= n {
|
||||
by_len.resize(n + 1, Vec::new());
|
||||
}
|
||||
let lvl = forms.iter().filter_map(|f| words.get(f)).min().copied().unwrap_or(50);
|
||||
let lvl = forms
|
||||
.iter()
|
||||
.filter_map(|f| words.get(f))
|
||||
.min()
|
||||
.copied()
|
||||
.unwrap_or(50);
|
||||
by_len[n].push((lw.clone(), lw.chars().collect(), lvl));
|
||||
}
|
||||
for v in &mut by_len {
|
||||
v.sort();
|
||||
}
|
||||
Dictionary { words, by_lower, by_len }
|
||||
Dictionary {
|
||||
words,
|
||||
by_lower,
|
||||
by_len,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
|
|
@ -90,16 +102,23 @@ impl Dictionary {
|
|||
let w = normalize(word);
|
||||
let w = w.strip_suffix("'s").filter(|b| !b.is_empty()).unwrap_or(&w);
|
||||
let w = w.trim_matches('\'');
|
||||
if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic) {
|
||||
if w.is_empty() || w.chars().any(|c| c.is_numeric()) || !w.chars().any(char::is_alphabetic)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let lower = w.to_lowercase();
|
||||
if user.contains(&lower) || self.words.contains_key(w) {
|
||||
return true;
|
||||
}
|
||||
let Some(forms) = self.by_lower.get(&lower) else { return false };
|
||||
let all_caps = w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase);
|
||||
let first_cap = w.chars().next().is_some_and(char::is_uppercase) && w.chars().skip(1).all(|c| !c.is_uppercase());
|
||||
let Some(forms) = self.by_lower.get(&lower) else {
|
||||
return false;
|
||||
};
|
||||
let all_caps = w
|
||||
.chars()
|
||||
.filter(|c| c.is_alphabetic())
|
||||
.all(char::is_uppercase);
|
||||
let first_cap = w.chars().next().is_some_and(char::is_uppercase)
|
||||
&& w.chars().skip(1).all(|c| !c.is_uppercase());
|
||||
forms.iter().any(|f| {
|
||||
let f_lower = f.chars().all(|c| !c.is_uppercase());
|
||||
// "the" → "The"/"THE"; "Paris" → "PARIS"; "NASA" stays as listed (exact match above).
|
||||
|
|
@ -126,7 +145,9 @@ impl Dictionary {
|
|||
let mut found: Vec<(usize, bool, u8, &str)> = Vec::new();
|
||||
let mut rows = Rows::default();
|
||||
for len in n.saturating_sub(max_d)..=(n + max_d) {
|
||||
let Some(bucket) = self.by_len.get(len) else { continue };
|
||||
let Some(bucket) = self.by_len.get(len) else {
|
||||
continue;
|
||||
};
|
||||
for (cand, cc, lvl) in bucket {
|
||||
if let Some(d) = distance(&lc, cc, max_d, &mut rows) {
|
||||
found.push((d, !anagram(cand), *lvl, cand));
|
||||
|
|
@ -134,7 +155,11 @@ impl Dictionary {
|
|||
}
|
||||
}
|
||||
found.sort();
|
||||
let all_caps = w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase) && w.chars().filter(|c| c.is_alphabetic()).count() > 1;
|
||||
let all_caps = w
|
||||
.chars()
|
||||
.filter(|c| c.is_alphabetic())
|
||||
.all(char::is_uppercase)
|
||||
&& w.chars().filter(|c| c.is_alphabetic()).count() > 1;
|
||||
let first_cap = w.chars().next().is_some_and(char::is_uppercase);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (_, _, _, cand) in found {
|
||||
|
|
@ -166,7 +191,11 @@ impl Dictionary {
|
|||
words(text)
|
||||
.into_iter()
|
||||
.filter(|(a, b)| !self.check(&text[*a..*b], user))
|
||||
.map(|(a, b)| Misspelling { start: a, end: b, word: text[a..b].to_string() })
|
||||
.map(|(a, b)| Misspelling {
|
||||
start: a,
|
||||
end: b,
|
||||
word: text[a..b].to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -191,7 +220,9 @@ pub fn words(text: &str) -> Vec<(usize, usize)> {
|
|||
let mut start: Option<usize> = None;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
for (i, &(b, c)) in chars.iter().enumerate() {
|
||||
let inner_apos = is_apos(c) && start.is_some() && chars.get(i + 1).is_some_and(|(_, n)| n.is_alphabetic());
|
||||
let inner_apos = is_apos(c)
|
||||
&& start.is_some()
|
||||
&& chars.get(i + 1).is_some_and(|(_, n)| n.is_alphabetic());
|
||||
if c.is_alphanumeric() || inner_apos {
|
||||
start.get_or_insert(b);
|
||||
} else if let Some(s) = start.take() {
|
||||
|
|
@ -258,7 +289,20 @@ mod tests {
|
|||
fn bundled_dictionary_loads() {
|
||||
let d = Dictionary::english();
|
||||
assert!(d.len() > 70_000, "{}", d.len());
|
||||
for w in ["the", "The", "THE", "color", "colors", "running", "don't", "don’t", "Paris", "PARIS", "photographer's", "NASA"] {
|
||||
for w in [
|
||||
"the",
|
||||
"The",
|
||||
"THE",
|
||||
"color",
|
||||
"colors",
|
||||
"running",
|
||||
"don't",
|
||||
"don’t",
|
||||
"Paris",
|
||||
"PARIS",
|
||||
"photographer's",
|
||||
"NASA",
|
||||
] {
|
||||
assert!(d.check(w, &none()), "{w}");
|
||||
}
|
||||
for w in ["teh", "recieve", "paris", "Photograpy", "zxqv"] {
|
||||
|
|
@ -275,14 +319,23 @@ mod tests {
|
|||
assert!(d.suggest("recieve", 5).contains(&"receive".to_string()));
|
||||
assert_eq!(d.suggest("Wrold", 1), vec!["World".to_string()]);
|
||||
assert_eq!(d.suggest("brwon", 1), vec!["brown".to_string()]);
|
||||
assert_eq!(d.suggest("HELO", 5).first().map(|s| s.chars().all(|c| c.is_uppercase())), Some(true));
|
||||
assert_eq!(
|
||||
d.suggest("HELO", 5)
|
||||
.first()
|
||||
.map(|s| s.chars().all(|c| c.is_uppercase())),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspellings_and_user_words() {
|
||||
let d = Dictionary::from_list("#10\nhello\nworld\nParis\n");
|
||||
let text = "Hello wrld, Paris paris photocraft 42x";
|
||||
let m: Vec<String> = d.misspellings(text, &none()).into_iter().map(|m| m.word).collect();
|
||||
let m: Vec<String> = d
|
||||
.misspellings(text, &none())
|
||||
.into_iter()
|
||||
.map(|m| m.word)
|
||||
.collect();
|
||||
assert_eq!(m, vec!["wrld", "paris", "photocraft"]);
|
||||
let user: HashSet<String> = ["photocraft".to_string()].into();
|
||||
assert_eq!(d.misspellings(text, &user).len(), 2);
|
||||
|
|
@ -298,7 +351,18 @@ mod tests {
|
|||
let d = Dictionary::english();
|
||||
let load = t.elapsed();
|
||||
let t = std::time::Instant::now();
|
||||
for w in ["recieve", "teh", "photograpy", "definately", "seperate", "wierd", "acommodate", "begining", "occurence", "untill"] {
|
||||
for w in [
|
||||
"recieve",
|
||||
"teh",
|
||||
"photograpy",
|
||||
"definately",
|
||||
"seperate",
|
||||
"wierd",
|
||||
"acommodate",
|
||||
"begining",
|
||||
"occurence",
|
||||
"untill",
|
||||
] {
|
||||
let _ = d.suggest(w, 8);
|
||||
}
|
||||
eprintln!("load {load:?}, suggest {:?}/word", t.elapsed() / 10);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ fn effects() -> Vec<Effect> {
|
|||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
//! End-to-end timings of the interactive paths on a large photo (default 7360×4912 = 36 MP):
|
||||
//! open JPEG / PSD, brush dab, Gaussian Blur, adjustment layer tweak, layer move, undo,
|
||||
//! save .pcraft / .psd, export PNG / JPEG, plus the canvas refresh each edit triggers.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example interactive_bench -- [--size 7360x4912] [--reps 5] [--json out.json]
|
||||
//! ```
|
||||
//!
|
||||
//! Every edit goes through `Session::execute` (the command path the UI, CLI and MCP share:
|
||||
//! history snapshot, command, damage), then the canvas refresh the app would do: the GPU
|
||||
//! compositor over the edit's damage rect (or the whole document), waited on so the time is the
|
||||
//! real latency. Without a GPU adapter the CPU compositor stands in. Zoom and pan don't
|
||||
//! recomposite (the canvas samples the composited texture's mip levels); "viewport (CPU)" times
|
||||
//! what the CPU fallback pays for one 1920×1080 view. Each row is the median of `--reps` runs
|
||||
//! (min / max shown); the first run of an operation is reported separately as "cold".
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::wgpu;
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn block_on<F: std::future::Future>(f: F) -> F::Output {
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
let mut f = std::pin::pin!(f);
|
||||
loop {
|
||||
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
|
||||
return v;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
comp: photocraft_gpu::Compositor,
|
||||
}
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })).ok()?;
|
||||
eprintln!("adapter: {}", adapter.get_info().name);
|
||||
let limits = adapter.limits();
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?;
|
||||
let comp = photocraft_gpu::Compositor::new(&device);
|
||||
Some(Gpu { device, queue, comp })
|
||||
}
|
||||
|
||||
/// A photo-like RGB image: smooth gradients, soft blobs, fine noise (so JPEG has real work).
|
||||
fn photo(w: u32, h: u32) -> photocraft_codecs::Image {
|
||||
use rayon::prelude::*;
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06;
|
||||
let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06;
|
||||
let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06;
|
||||
for (c, v) in [r, g, b].into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image")
|
||||
}
|
||||
|
||||
struct Bench {
|
||||
gpu: Option<Gpu>,
|
||||
reps: usize,
|
||||
rows: Vec<(String, f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
/// Canvas refresh after an edit: the damage rect when the session reports one for this
|
||||
/// revision, else the whole document (what `canvas::ensure_gpu` does).
|
||||
fn refresh(&mut self, s: &Session, full: bool) -> f64 {
|
||||
let st = s.active().expect("document");
|
||||
let doc = st.doc.clone();
|
||||
let region = if full { doc.bounds() } else { st.last_damage.map_or(doc.bounds(), |r| r.inflate(photocraft_compose::effects::margin_all(&doc.layers)).intersect(&doc.bounds())) };
|
||||
if region.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = Instant::now();
|
||||
match &mut self.gpu {
|
||||
Some(g) => {
|
||||
if g.comp.render(&g.device, &g.queue, &doc, region, |_, _| {}).is_err() {
|
||||
std::hint::black_box(photocraft_compose::render(&doc, region));
|
||||
}
|
||||
let _ = g.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
std::hint::black_box(photocraft_compose::render(&doc, region));
|
||||
}
|
||||
}
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// Times `f` (`reps` runs after a cold one); `f` returns its own breakdown-free total in ms.
|
||||
fn time(&mut self, name: &str, mut f: impl FnMut(&mut Self) -> f64) {
|
||||
let cold = f(self);
|
||||
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
let med = v[v.len() / 2];
|
||||
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold);
|
||||
self.rows.push((name.to_string(), med, v[0], v[v.len() - 1], cold));
|
||||
}
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn exec(s: &mut Session, id: &str, p: Value) {
|
||||
if let Err(e) = s.execute(id, p) {
|
||||
panic!("{id}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
|
||||
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, rows: Vec::new() };
|
||||
println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" });
|
||||
|
||||
// ---- open / save / export ----------------------------------------------------------------
|
||||
let img = photo(w, h);
|
||||
let mut jpeg = Vec::new();
|
||||
b.time("encode JPEG (codecs, q 90)", |_| {
|
||||
let t = Instant::now();
|
||||
jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).expect("jpeg");
|
||||
ms(t)
|
||||
});
|
||||
let mut s = Session::new();
|
||||
b.time("open JPEG (decode + document + refresh)", |b| {
|
||||
let t = Instant::now();
|
||||
let doc = photocraft_io::import("photo.jpg", &jpeg).expect("import").document;
|
||||
s = Session::new();
|
||||
s.open_document(doc, Some("photo.jpg".into()));
|
||||
let t = ms(t);
|
||||
t + b.refresh(&s, true)
|
||||
});
|
||||
// A working document: the photo, a painting layer above it, an adjustment on top later.
|
||||
exec(&mut s, "layer.new.layer", json!({"name": "paint"}));
|
||||
let doc = |s: &Session| -> std::sync::Arc<Document> { s.active().expect("doc").doc.clone() };
|
||||
b.time("full refresh (2 layers)", |b| b.refresh(&s, true));
|
||||
b.time("viewport 1920×1080 (CPU compose)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(photocraft_compose::render(&d, Rect::from_xywh(w as i32 / 3, h as i32 / 3, 1920, 1080)));
|
||||
ms(t)
|
||||
});
|
||||
|
||||
// ---- painting --------------------------------------------------------------------------
|
||||
let mut k = 0;
|
||||
b.time("brush dab 60 px (command + refresh)", |b| {
|
||||
k += 1;
|
||||
let (x, y) = (200 + (k * 97) % (w as i32 - 400), 200 + (k * 61) % (h as i32 - 400));
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
b.time("brush stroke 40 dabs 120 px", |b| {
|
||||
k += 1;
|
||||
let (x, y) = (300 + (k * 131) % (w as i32 - 2000), 300 + (k * 71) % (h as i32 - 600));
|
||||
let pts: Vec<Value> = (0..40).map(|i| json!([x + i * 30, y + ((i as f32 * 0.4).sin() * 80.0) as i32])).collect();
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "paint.stroke", json!({"points": pts, "size": 120, "hardness": 0.5, "color": "#2040c0", "spacing": 0.25}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
b.time("undo (stroke)", |b| {
|
||||
exec(&mut s, "paint.stroke", json!({"points": [[500, 500], [900, 700]], "size": 80, "color": "#20a040"}));
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
|
||||
// ---- filters / adjustments / layers ------------------------------------------------------
|
||||
exec(&mut s, "layer.select", json!({"index": 0})).to_owned();
|
||||
for radius in [4.0, 40.0] {
|
||||
b.time(&format!("Gaussian Blur r {radius} (photo layer) + refresh"), |b| {
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "filter.blur.gaussianBlur", json!({"radius": radius}));
|
||||
let v = ms(t) + b.refresh(&s, false);
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
b.refresh(&s, false);
|
||||
v
|
||||
});
|
||||
}
|
||||
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 10, "inWhite": 240, "gamma": 1.1}));
|
||||
let mut g = 1.0;
|
||||
b.time("Levels layer tweak (gamma) + full refresh", |b| {
|
||||
g = if g > 1.5 { 0.8 } else { g + 0.07 };
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "layer.setAdjustment", json!({"gamma": g}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
exec(&mut s, "layer.select", json!({"index": 1}));
|
||||
let mut d = 1;
|
||||
b.time("move paint layer 10 px + refresh", |b| {
|
||||
d = -d;
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "layer.translate", json!({"dx": 10 * d, "dy": 5 * d}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
b.time("undo (move)", |b| {
|
||||
exec(&mut s, "layer.translate", json!({"dx": 7, "dy": 3}));
|
||||
let t = Instant::now();
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
ms(t) + b.refresh(&s, false)
|
||||
});
|
||||
|
||||
// ---- save / export ---------------------------------------------------------------------
|
||||
let mut psd = Vec::new();
|
||||
b.time("save .pcraft (bytes, full)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(photocraft_format::save_to_bytes(&d, &photocraft_format::SaveOptions::default()).expect("pcraft"));
|
||||
ms(t)
|
||||
});
|
||||
b.time("save .psd", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
psd = photocraft_io::export(&d, "x.psd", &Default::default()).expect("psd").bytes;
|
||||
ms(t)
|
||||
});
|
||||
b.time("open .psd (3 layers) + refresh", |b| {
|
||||
let t = Instant::now();
|
||||
let d = photocraft_io::import("x.psd", &psd).expect("psd").document;
|
||||
let mut s2 = Session::new();
|
||||
s2.open_document(d, None);
|
||||
ms(t) + b.refresh(&s2, true)
|
||||
});
|
||||
b.time("export PNG (flatten + encode)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(photocraft_io::export(&d, "x.png", &Default::default()).expect("png"));
|
||||
ms(t)
|
||||
});
|
||||
b.time("export JPEG (flatten + encode)", |_| {
|
||||
let d = doc(&s);
|
||||
let t = Instant::now();
|
||||
std::hint::black_box(photocraft_io::export(&d, "x.jpg", &Default::default()).expect("jpg"));
|
||||
ms(t)
|
||||
});
|
||||
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let rows: Vec<Value> = b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect();
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap()).expect("write json");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue