mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Integration: track t04
# Conflicts: # ATTRIBUTION.md # contributors/people.toml # crates/ui-egui/src/i18n/cs.tsv # crates/ui-egui/src/i18n/de.tsv # crates/ui-egui/src/i18n/el.tsv # crates/ui-egui/src/i18n/es.tsv # crates/ui-egui/src/i18n/fr.tsv # crates/ui-egui/src/i18n/hu.tsv # crates/ui-egui/src/i18n/id.tsv # crates/ui-egui/src/i18n/it.tsv # crates/ui-egui/src/i18n/ja.tsv # crates/ui-egui/src/i18n/ko.tsv # crates/ui-egui/src/i18n/nl.tsv # crates/ui-egui/src/i18n/pl.tsv # crates/ui-egui/src/i18n/pt-br.tsv # crates/ui-egui/src/i18n/ru.tsv # crates/ui-egui/src/i18n/sr.tsv # crates/ui-egui/src/i18n/tr.tsv # crates/ui-egui/src/i18n/uk.tsv # crates/ui-egui/src/i18n/vi.tsv # crates/ui-egui/src/i18n/zh-hans.tsv # crates/ui-egui/src/i18n/zh-hant.tsv # docs/gaps.md
This commit is contained in:
+3
-1
@@ -25,9 +25,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -36,6 +37,7 @@ next to them.
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
|
||||
| Korean | `ko` | 100% | partial |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -136,6 +136,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
|
||||
<path d="M6 5V3h12v2" />
|
||||
<path d="M12 3v11" />
|
||||
<path d="M9.5 14h5" />
|
||||
<path d="M3 17c5 5 13 5 18 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -201,3 +201,16 @@ display_name = "Jason Brashear"
|
||||
[people.AndreeSalazar]
|
||||
real_name = "Andreé Salazar"
|
||||
sync_github_name = true
|
||||
|
||||
[people.LorenzoFasolino]
|
||||
real_name = "Lorenzo Fasolino"
|
||||
display_name = "Lorenzo Fasolino"
|
||||
sync_github_name = false
|
||||
|
||||
[people.zolyy]
|
||||
real_name = "Gall Zoltan"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theshubh77]
|
||||
real_name = "Shubham Bhamare"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -1190,13 +1190,13 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"count": 0, "layers": 0}));
|
||||
}
|
||||
s.edit("Replace All Text", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
for (id, matches) in &plan {
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) {
|
||||
for (a, b) in matches.iter().rev() {
|
||||
crate::type_cmds::replace_text(t, *a, *b, &replace);
|
||||
}
|
||||
crate::type_cmds::refresh(&snapshot, t);
|
||||
crate::type_cmds::refresh_at(dpi, format, t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1214,10 +1214,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
crate::type_cmds::check_text_unlocked(&doc, id)?;
|
||||
s.edit("Replace Text", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
crate::type_cmds::replace_text(t, a, b, &replace);
|
||||
crate::type_cmds::refresh(&snapshot, t);
|
||||
crate::type_cmds::refresh_at(dpi, format, t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -1089,7 +1089,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"plugIns.allowScriptsToConnect",
|
||||
"plugIns.generatorEnabled",
|
||||
"type.textEngine",
|
||||
"type.recentFonts",
|
||||
"enhancedControls.scrubbySliderAcceleration",
|
||||
"rawDefaults.colorSpace",
|
||||
"rawDefaults.bitDepth",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::Color;
|
||||
use photocraft_color::{Color, PixelFormat};
|
||||
use photocraft_doc::text::{
|
||||
AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection,
|
||||
TextRun, TextShape,
|
||||
@@ -250,6 +250,19 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
|
||||
s.font_family = v.to_string();
|
||||
s.postscript_name = None;
|
||||
// Photoshop accepts either a family name or a face's PostScript name in the font field.
|
||||
// Resolve only exact PostScript matches here so an unknown family remains untouched.
|
||||
let mut text = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !text.fonts.has_family(v) {
|
||||
let resolved = text.fonts.resolve_postscript(v);
|
||||
if resolved.exact {
|
||||
s.variations.retain(|f| f.axis != "wght");
|
||||
s.font_family = resolved.family;
|
||||
s.weight = resolved.weight;
|
||||
s.italic = resolved.italic;
|
||||
s.postscript_name = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
hit(true);
|
||||
}
|
||||
if let Some(v) = p.get("fontStyle").and_then(Value::as_str) {
|
||||
@@ -590,9 +603,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) {
|
||||
|
||||
/// Re-renders the layer's pixels and regenerates its PSD `TySh` data.
|
||||
pub fn refresh(doc: &Document, t: &mut TextLayer) {
|
||||
let dpi = doc.resolution_dpi;
|
||||
refresh_at(doc.resolution_dpi, doc.pixel_format(), t);
|
||||
}
|
||||
|
||||
/// [`refresh`] given only the two things it reads from the document: its resolution and its
|
||||
/// pixel format. A caller holding the layer mutably reads them first instead of cloning the whole
|
||||
/// document to pass it, once per keystroke and pointer move while typing (#7092).
|
||||
pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) {
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (layout, r) = eng.render(t, dpi, doc.pixel_format());
|
||||
let (layout, r) = eng.render(t, dpi, format);
|
||||
t.cache = Some(r.surface);
|
||||
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
|
||||
}
|
||||
@@ -609,11 +628,12 @@ pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> {
|
||||
|
||||
/// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s
|
||||
/// `name`), which is applied in the same step; otherwise an auto-named layer follows its text.
|
||||
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option<String>) -> Result<R>) -> Result<R> {
|
||||
/// `f` gets the document's resolution (dpi).
|
||||
fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32, &mut Option<String>) -> Result<R>) -> Result<R> {
|
||||
let id = layer_id(s, p)?;
|
||||
check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
|
||||
let (r, damage) = s.edit(label, |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let auto_named = is_auto_named(l);
|
||||
let LayerContent::Text(t) = &mut l.content else {
|
||||
@@ -621,8 +641,8 @@ fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m
|
||||
};
|
||||
let before = t.cache.as_ref().map(|c| c.tile_bounds());
|
||||
let mut rename = None;
|
||||
let r = f(t, &snapshot, &mut rename)?;
|
||||
refresh(&snapshot, t);
|
||||
let r = f(t, dpi, &mut rename)?;
|
||||
refresh_at(dpi, format, t);
|
||||
// Only this layer's pixels changed: the canvas recomposites their old and new area
|
||||
// instead of the whole document (#124). Unknown old pixels mean a full refresh.
|
||||
let damage = before.zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|(a, b)| a.union(&b));
|
||||
@@ -689,14 +709,14 @@ pub(crate) fn with_text_targets(
|
||||
check_text_unlocked(doc, *id)?;
|
||||
}
|
||||
let damage = s.edit(label, |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let mut damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
for id in &ids {
|
||||
let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) else { return Err(EngineError::NoLayer(*id)) };
|
||||
let before = t.cache.as_ref().map(|c| c.tile_bounds());
|
||||
let range = if p.get("layers").is_none() || p.get("layer").is_some() { range_param(&t.text, p) } else { (0, t.text.len()) };
|
||||
f(t, range)?;
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
// An unknown old/new cache retains Session.edit's full-refresh fallback.
|
||||
damage = damage.zip(before).zip(t.cache.as_ref().map(|c| c.tile_bounds())).map(|((all, a), b)| all.union(&a).union(&b));
|
||||
}
|
||||
@@ -917,7 +937,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" };
|
||||
with_text_layer(s, p, label, |t, doc, rename| {
|
||||
with_text_layer(s, p, label, |t, dpi, rename| {
|
||||
if let Some((at, by)) = kern_pair {
|
||||
// Photoshop's Alt+←/→: the pair before the caret becomes manually kerned,
|
||||
// starting from what it shows now (its metrics/optical or manual value).
|
||||
@@ -929,7 +949,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let b = byte_at(&t.text, at);
|
||||
let now = {
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
eng.pair_kerning(t, doc.resolution_dpi, a)
|
||||
eng.pair_kerning(t, dpi, a)
|
||||
}
|
||||
.ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?;
|
||||
let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32;
|
||||
@@ -1045,16 +1065,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
run: |s, p| {
|
||||
let id = layer_id(s, p)?;
|
||||
s.edit("Rasterize Type", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Text(t) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("layer {} is not a type layer", id.0)));
|
||||
};
|
||||
if t.cache.is_none() {
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
}
|
||||
let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(snapshot.pixel_format()));
|
||||
let surface = if surface.format() == snapshot.pixel_format() { surface } else { surface.convert(snapshot.pixel_format()) };
|
||||
let surface = t.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(format));
|
||||
let surface = if surface.format() == format { surface } else { surface.convert(format) };
|
||||
l.content = LayerContent::Raster(surface);
|
||||
l.psd_blocks.retain(|(k, _)| k != b"TySh");
|
||||
Ok(())
|
||||
|
||||
@@ -15,7 +15,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, explicit_layer, layer_param};
|
||||
use crate::type_cmds::{
|
||||
check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets,
|
||||
check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh_at, replace_text, style_paragraphs, style_range,
|
||||
with_text_targets,
|
||||
};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
@@ -68,19 +69,20 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Edits a type layer as one history step and re-renders it.
|
||||
fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result<R>) -> Result<R> {
|
||||
/// Edits a type layer as one history step and re-renders it. `f` gets the document's resolution
|
||||
/// (dpi).
|
||||
fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, f32) -> Result<R>) -> Result<R> {
|
||||
let id = layer_param(s, p)?;
|
||||
check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
|
||||
s.edit(label, |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let auto_named = is_auto_named(l);
|
||||
let LayerContent::Text(t) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name())));
|
||||
};
|
||||
let r = f(t, &snapshot)?;
|
||||
refresh(&snapshot, t);
|
||||
let r = f(t, dpi)?;
|
||||
refresh_at(dpi, format, t);
|
||||
let name = auto_named.then(|| layer_name(&t.text));
|
||||
if let Some(n) = name {
|
||||
l.name = n;
|
||||
@@ -90,7 +92,11 @@ fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex
|
||||
}
|
||||
|
||||
fn layout(doc: &Document, t: &TextLayer) -> photocraft_text::TextLayout {
|
||||
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, doc.resolution_dpi)
|
||||
layout_at(doc.resolution_dpi, t)
|
||||
}
|
||||
|
||||
fn layout_at(dpi: f32, t: &TextLayer) -> photocraft_text::TextLayout {
|
||||
photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).layout(t, dpi)
|
||||
}
|
||||
|
||||
/// `m ∘ translate(tx, ty)`.
|
||||
@@ -144,11 +150,11 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
|
||||
// ---------- point ⇄ paragraph ----------
|
||||
|
||||
fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| {
|
||||
let l = layout(doc, t);
|
||||
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0;
|
||||
let r = with_text(s, p, "Convert to Paragraph Text", |t, dpi| {
|
||||
let l = layout_at(dpi, t);
|
||||
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * dpi / 72.0;
|
||||
let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default();
|
||||
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0;
|
||||
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * dpi / 72.0;
|
||||
if l.vertical {
|
||||
// Line space: u along the columns, v across them (first column centred on v = 0).
|
||||
let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]);
|
||||
@@ -178,7 +184,7 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
probe.shape = TextShape::Box { x, y: y0, width, height };
|
||||
// Box text puts the first baseline at the box top plus the ascender height: measure it and
|
||||
// shift the box so that baseline stays at the point-text anchor (y = 0).
|
||||
let b0 = layout(doc, &probe).lines.first().map_or(0.0, |ln| ln.baseline);
|
||||
let b0 = layout_at(dpi, &probe).lines.first().map_or(0.0, |ln| ln.baseline);
|
||||
let y = y0 - b0;
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height };
|
||||
t.transform = then_translate(&t.transform, f64::from(x), f64::from(y));
|
||||
@@ -188,9 +194,9 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = with_text(s, p, "Convert to Point Text", |t, doc| {
|
||||
let r = with_text(s, p, "Convert to Point Text", |t, dpi| {
|
||||
let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) };
|
||||
let l = layout(doc, t);
|
||||
let l = layout_at(dpi, t);
|
||||
// Vertical type: columns run along the box height.
|
||||
let (x, width) = if l.vertical { (y, height) } else { (x, width) };
|
||||
let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline);
|
||||
@@ -368,13 +374,24 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?,
|
||||
};
|
||||
// A document point: the type starts where the path passes nearest it (the Type tool's click).
|
||||
let at = match p.get("at") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
Some(xy.filter(|(x, y)| x.is_finite() && y.is_finite()).ok_or_else(|| bad("\"at\" must be [x, y] in document pixels"))?)
|
||||
}
|
||||
};
|
||||
if at.is_some() && p.get("start").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("pass \"start\" or \"at\", not both"));
|
||||
}
|
||||
let id = layer_param(s, p)?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
if doc.effective_locks(id).all {
|
||||
return Err(EngineError::Other("the type layer is locked".into()));
|
||||
}
|
||||
let sub = path_source(doc, p)?;
|
||||
with_text(s, p, "Type on a Path", |t, _| {
|
||||
let start = with_text(s, p, "Type on a Path", |t, _| {
|
||||
// The path lives in document space; the type layer keeps it in its own text space, so
|
||||
// the type lands on the path where it is drawn.
|
||||
let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?;
|
||||
@@ -384,8 +401,15 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) {
|
||||
return Err(EngineError::Other("the path cannot be mapped into the type layer".into()));
|
||||
}
|
||||
let start = match at {
|
||||
None => start,
|
||||
Some((x, y)) => {
|
||||
let q = map(Point::new(x, y));
|
||||
photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32
|
||||
}
|
||||
};
|
||||
t.shape = TextShape::Path { path, start, flip };
|
||||
Ok(())
|
||||
Ok(start)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "start": start, "flip": flip}))
|
||||
}
|
||||
@@ -499,11 +523,11 @@ fn paste_lorem(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn update_all(s: &mut Session) -> Result<Value> {
|
||||
let mut n = 0;
|
||||
s.edit("Update All Text Layers", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let ids: Vec<LayerId> = snapshot.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let ids: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
|
||||
for id in ids {
|
||||
if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(id) {
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -608,7 +632,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
|
||||
}
|
||||
let mut n = 0;
|
||||
s.edit("Replace Missing Fonts", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
for id in &ids {
|
||||
if let Some(Layer { content: LayerContent::Text(t), .. }) = doc.layer_mut(*id) {
|
||||
let mut changed = false;
|
||||
@@ -629,7 +653,7 @@ fn replace_fonts(s: &mut Session, map: &serde_json::Map<String, Value>, all: boo
|
||||
at += r.len;
|
||||
}
|
||||
if changed {
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -722,7 +746,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"type.attachToPath",
|
||||
"Type on a Path",
|
||||
&[],
|
||||
r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##,
|
||||
r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"at":[x,y]? (instead of "start": begin where the path passes nearest this document point, as a Type tool click does),"flip":bool=false (run along the path the other way, on its other side)}"##,
|
||||
has_text,
|
||||
attach_to_path
|
||||
),
|
||||
|
||||
@@ -314,6 +314,34 @@ fn type_attaches_to_a_path_and_releases_it() {
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { .. }));
|
||||
}
|
||||
|
||||
/// The Type tool's click on a path: `"at"` starts the type where the path passes nearest the
|
||||
/// click, and the paragraph alignment is kept about that point, as in Photoshop.
|
||||
#[test]
|
||||
fn type_attaches_to_a_path_at_a_document_point() {
|
||||
let mut s = session(8);
|
||||
s.edit("path", |doc, _| {
|
||||
doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (290.0, 60.0)])]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for (align, centred) in [("left", false), ("center", true)] {
|
||||
let r = s.execute("type.create", json!({"x": 150, "y": 60, "text": "Path", "size": 24, "align": align, "color": "#000000"})).unwrap();
|
||||
let id = LayerId(r["layer"].as_u64().unwrap());
|
||||
assert!(s.execute("type.attachToPath", json!({"at": [150, "y"]})).is_err());
|
||||
assert!(s.execute("type.attachToPath", json!({"at": [150]})).is_err());
|
||||
assert!(s.execute("type.attachToPath", json!({"at": [150, 60], "start": 3})).is_err());
|
||||
let r = s.execute("type.attachToPath", json!({"at": [150, 64]})).unwrap();
|
||||
assert!((r["start"].as_f64().unwrap() - 140.0).abs() < 0.5, "{r}");
|
||||
let b = ink(&s, id);
|
||||
assert!(b.y1 <= 61, "the type sits on the path: {b:?}");
|
||||
if centred {
|
||||
assert!(b.x0 < 145 && b.x1 > 155, "centred on the click: {b:?}");
|
||||
} else {
|
||||
assert!((148..=156).contains(&b.x0), "starts at the click: {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() {
|
||||
// #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer.
|
||||
|
||||
@@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, layer_param};
|
||||
use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text};
|
||||
use crate::type_cmds::{check_text_unlocked, norm_text, refresh_at, replace_text};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
@@ -102,10 +102,10 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("the text at {start}..{end} is {:?}, not {w:?}", &t[a..b])));
|
||||
}
|
||||
s.edit("Check Spelling", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
replace_text(t, a, b, &to);
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -129,13 +129,13 @@ fn change_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let count: usize = plan.iter().map(|(_, h)| h.len()).sum();
|
||||
if count > 0 {
|
||||
s.edit("Check Spelling", |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
for (id, hits) in &plan {
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) {
|
||||
for (a, b) in hits.iter().rev() {
|
||||
replace_text(t, *a, *b, &to);
|
||||
}
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -215,10 +215,10 @@ fn insert_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ba, bb) = (byte_at(t, a), byte_at(t, b));
|
||||
let label = p.get("label").and_then(Value::as_str).unwrap_or("Insert Glyph").to_string();
|
||||
s.edit(&label, |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
replace_text(t, ba, bb, &new);
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -17,7 +17,7 @@ use photocraft_doc::{Document, LayerContent, LayerId, TextLayer, TextStyles};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::type_cmds::{apply_char_props, apply_para_props, refresh};
|
||||
use crate::type_cmds::{apply_char_props, apply_para_props, refresh_at};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const FAMILY: &str = photocraft_text::fonts::DEFAULT_FAMILY;
|
||||
@@ -292,7 +292,7 @@ fn commit(s: &mut Session, label: &str, new: TextStyles, target: &[Target], op:
|
||||
fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op) -> usize {
|
||||
let old = doc.text_styles.clone();
|
||||
doc.text_styles = new.clone();
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let ids: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
|
||||
let mut changed = 0;
|
||||
for id in ids {
|
||||
@@ -303,7 +303,7 @@ fn apply_styles(doc: &mut Document, new: &TextStyles, target: &[Target], op: Op)
|
||||
None => restyle(t, &old, new, Some((0, 0)), Op::default()),
|
||||
};
|
||||
if did {
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
@@ -517,7 +517,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
|
||||
} else {
|
||||
doc.text_styles.character.retain(|d| d.id != id);
|
||||
}
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let ids: Vec<LayerId> = doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
for lid in ids {
|
||||
let Some(LayerContent::Text(t)) = doc.layer_mut(lid).map(|l| &mut l.content) else { continue };
|
||||
@@ -538,7 +538,7 @@ fn delete(s: &mut Session, p: &Value, paragraph: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
refresh(&snapshot, t);
|
||||
refresh_at(dpi, format, t);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<(
|
||||
}
|
||||
}
|
||||
(VarKind::TextReplacement, VarValue::Text(text)) => {
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
if let Some(l) = doc.layer_mut(def.layer)
|
||||
&& let LayerContent::Text(t) = &mut l.content
|
||||
{
|
||||
@@ -180,7 +180,7 @@ fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<(
|
||||
t.sync_summary();
|
||||
// The compositor draws a type layer from its cache: clearing it left the
|
||||
// layer blank until some other edit re-rendered it (#990).
|
||||
crate::type_cmds::refresh(&snapshot, t);
|
||||
crate::type_cmds::refresh_at(dpi, format, t);
|
||||
}
|
||||
}
|
||||
(VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => {
|
||||
|
||||
@@ -355,7 +355,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result<
|
||||
}
|
||||
s.edit("Warp", |doc, _| {
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
let snapshot = doc.clone();
|
||||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let locks = l.locks.union(group);
|
||||
if locks.position || locks.all {
|
||||
@@ -363,7 +363,7 @@ fn warp_type_layer(s: &mut Session, cmd: &str, id: LayerId, w: &Warp) -> Result<
|
||||
}
|
||||
let LayerContent::Text(t) = &mut l.content else { return Err(EngineError::Other("not a type layer".into())) };
|
||||
t.warp = tw.clone();
|
||||
crate::type_cmds::refresh(&snapshot, t);
|
||||
crate::type_cmds::refresh_at(dpi, format, t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "changed": true, "warp": tw}))
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! `type.create` accepts an exact PostScript face name in its font field.
|
||||
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn font_with_postscript_name(source: &[u8], postscript: &str) -> Vec<u8> {
|
||||
let mut bytes = source.to_vec();
|
||||
let count = u16::from_be_bytes(bytes[4..6].try_into().unwrap()) as usize;
|
||||
let name_record = (0..count).map(|i| 12 + i * 16).find(|r| &bytes[*r..r + 4] == b"name").unwrap();
|
||||
for i in 0..count {
|
||||
let r = 12 + i * 16;
|
||||
let offset = u32::from_be_bytes(bytes[r + 8..r + 12].try_into().unwrap()) as usize;
|
||||
if &bytes[r..r + 4] == b"OS/2" {
|
||||
bytes[offset + 4..offset + 6].copy_from_slice(&900u16.to_be_bytes());
|
||||
}
|
||||
}
|
||||
let names = [(1u16, "PhotoCraft Black Test"), (2, "Black"), (6, postscript), (16, "PhotoCraft Black Test"), (17, "Black")];
|
||||
let mut table = Vec::new();
|
||||
let mut strings = Vec::new();
|
||||
for value in [0u16, names.len() as u16, (6 + names.len() * 12) as u16] {
|
||||
table.extend(value.to_be_bytes());
|
||||
}
|
||||
for (id, value) in names {
|
||||
let encoded: Vec<_> = value.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for n in [3u16, 1, 0x409, id, encoded.len() as u16, strings.len() as u16] {
|
||||
table.extend(n.to_be_bytes());
|
||||
}
|
||||
strings.extend(encoded);
|
||||
}
|
||||
table.extend(strings);
|
||||
let offset = bytes.len() as u32;
|
||||
bytes[name_record + 8..name_record + 12].copy_from_slice(&offset.to_be_bytes());
|
||||
bytes[name_record + 12..name_record + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
|
||||
bytes.extend(table);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_create_resolves_exact_postscript_font_names() {
|
||||
let ps = "PhotoCraft-Black-Test";
|
||||
photocraft_text::shared()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.fonts
|
||||
.register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps));
|
||||
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
|
||||
let response = session.execute("type.create", json!({"text": "Test", "font": ps})).unwrap();
|
||||
assert!(response["warnings"].as_array().is_none_or(Vec::is_empty), "{response}");
|
||||
let id = LayerId(response["layer"].as_u64().unwrap());
|
||||
let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
|
||||
let style = &text.char_runs()[0].style;
|
||||
assert_eq!(style.font_family, "PhotoCraft Black Test");
|
||||
assert_eq!(style.postscript_name.as_deref(), Some(ps));
|
||||
assert_eq!(style.weight, 900);
|
||||
|
||||
let regular = session.execute("type.create", json!({"text": "Test", "font": "Inter"})).unwrap();
|
||||
assert!(regular["warnings"].as_array().is_none_or(Vec::is_empty), "{regular}");
|
||||
let missing = session.execute("type.create", json!({"text": "Test", "font": "No Such Family 123"})).unwrap();
|
||||
assert_eq!(missing["warnings"], json!(["font 'No Such Family 123' is not installed; text may render using a fallback face"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_postscript_face_clears_retained_weight_variation() {
|
||||
let ps = "PhotoCraft-Black-Test-Var";
|
||||
photocraft_text::shared()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.fonts
|
||||
.register_font_data(font_with_postscript_name(photocraft_text::fonts::INTER_REGULAR.as_slice(), ps));
|
||||
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"width": 200, "height": 100, "background": "transparent"})).unwrap();
|
||||
let response = session.execute("type.create", json!({"text": "Test", "font": "Inter", "variations": {"wght": 300}})).unwrap();
|
||||
let id = LayerId(response["layer"].as_u64().unwrap());
|
||||
|
||||
// Setting an exact PostScript face should clear the previously retained wght variation
|
||||
session.execute("type.setStyle", json!({"layer": id.0, "font": ps})).unwrap();
|
||||
let LayerContent::Text(text) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
|
||||
let style = &text.char_runs()[0].style;
|
||||
assert_eq!(style.font_family, "PhotoCraft Black Test");
|
||||
assert_eq!(style.weight, 900);
|
||||
assert!(!style.variations.iter().any(|v| v.axis == "wght"));
|
||||
|
||||
// Supplying a variations.wght in the same call should apply after clearing
|
||||
session.execute("type.setStyle", json!({"layer": id.0, "font": ps, "variations": {"wght": 700}})).unwrap();
|
||||
let LayerContent::Text(text2) = &session.active().unwrap().doc.layer(id).unwrap().content else { panic!("not a text layer") };
|
||||
let style2 = &text2.char_runs()[0].style;
|
||||
assert_eq!(style2.font_family, "PhotoCraft Black Test");
|
||||
assert_eq!(style2.weight, 900);
|
||||
assert_eq!(style2.variations.iter().find(|v| v.axis == "wght").map(|v| v.value), Some(700.0));
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Fonts from the optional craft-fonts build input (https://github.com/storytold/craft-fonts).
|
||||
//!
|
||||
//! `CRAFT_FONTS` is empty unless the app was built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`
|
||||
//! (see `build.rs`); every user of it must work when it is empty. Today it carries the Japanese
|
||||
//! fonts: BIZ UDPGothic (UI) and Shippori Mincho / BIZ UDMincho (serif document text). The web
|
||||
//! build (wasm32) embeds none of them: they don't fit its size cap (see `build.rs`).
|
||||
//! (see `build.rs`); every user of it must work when it is empty. It carries every font in the
|
||||
//! checkout's `fonts/manifest.txt`, whatever its scripts: the Japanese fonts BIZ UDPGothic (UI) and
|
||||
//! Shippori Mincho / BIZ UDMincho (serif document text) and, in newer checkouts, fonts for other
|
||||
//! scripts such as Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai.
|
||||
//! [`crate::fonts::FontDb::new`] registers all of them. The web build (wasm32) embeds none of
|
||||
//! them: they don't fit its size cap (see `build.rs`).
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
||||
pub struct CraftFont {
|
||||
|
||||
@@ -124,7 +124,7 @@ fn collection_isolation_preserves_requested_face() {
|
||||
let blob = Blob::new(Arc::new(bytes));
|
||||
assert!(isolated_face(blob.clone(), 2).is_none());
|
||||
let mut db = FontDb::new();
|
||||
db.register_blob(blob.clone());
|
||||
db.register_blob(blob.clone(), None);
|
||||
let mut style = CharStyle { font_family: "Numeric Test".into(), font_style: "30".into(), ..Default::default() };
|
||||
db.select_named_face(&mut style);
|
||||
assert_eq!(db.faces(&style.font_family)[0].postscript_name.as_deref(), Some("Collection30"));
|
||||
@@ -188,3 +188,66 @@ fn localized_family_names_map_english_families_to_native_names() {
|
||||
assert!(!local.contains_key(DEFAULT_FAMILY));
|
||||
assert!(local.values().all(|v| !v.is_empty()));
|
||||
}
|
||||
|
||||
/// A craft-fonts manifest entry for `bytes`, as `build.rs` writes one per manifest line.
|
||||
fn craft(family: &'static str, scripts: &'static [&'static str], bytes: Vec<u8>) -> crate::CraftFont {
|
||||
crate::CraftFont { family, style: "Regular", scripts, bytes: bytes.leak() }
|
||||
}
|
||||
|
||||
/// A TrueType collection of single-face fonts.
|
||||
fn collection(faces: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut bytes = b"ttcf\0\x01\0\0".to_vec();
|
||||
bytes.extend((faces.len() as u32).to_be_bytes());
|
||||
let mut start = 12 + 4 * faces.len();
|
||||
for face in faces {
|
||||
bytes.extend((start as u32).to_be_bytes());
|
||||
start += face.len();
|
||||
}
|
||||
for face in faces {
|
||||
let start = bytes.len();
|
||||
bytes.extend(face);
|
||||
let count = u16::from_be_bytes(bytes[start + 4..start + 6].try_into().unwrap()) as usize;
|
||||
for i in 0..count {
|
||||
let r = start + 12 + i * 16 + 8;
|
||||
let offset = u32::from_be_bytes(bytes[r..r + 4].try_into().unwrap()) + start as u32;
|
||||
bytes[r..r + 4].copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Single-face craft fonts of every script register under their manifest family names, whatever
|
||||
/// family their `name` tables give, and the ones the fallback lists name are in the fallback
|
||||
/// stack. The Hans and Arab families are fallback-list names, and no craft-fonts manifest uses
|
||||
/// any of the three, so `FontDb::new` does not register them from the build's own craft fonts.
|
||||
#[test]
|
||||
fn craft_fonts_of_every_script_register_under_their_manifest_families() {
|
||||
let fonts = [
|
||||
craft("WenQuanYi Micro Hei", &["Hans", "Latn"], localized_font(&INTER_REGULAR, "WenQuanYi Micro Hei", "WenQuanYi Micro Hei")),
|
||||
// The `name` tables of these two give the family "Numeric Test".
|
||||
craft("Geeza Pro", &["Arab"], numeric_font(&INTER_REGULAR, "20", "CraftTestArabic")),
|
||||
craft("Craft Test Gothic", &["Jpan", "Latn"], numeric_font(&INTER_SEMIBOLD, "30", "CraftTestGothic")),
|
||||
];
|
||||
let mut db = FontDb::new();
|
||||
assert!(fonts.iter().all(|f| !db.has_family(f.family)));
|
||||
db.register_craft_fonts(&fonts);
|
||||
let families = db.families();
|
||||
for f in &fonts {
|
||||
assert!(families.iter().any(|n| n == f.family), "{} registered under its manifest family: {families:?}", f.family);
|
||||
}
|
||||
assert!(!db.has_family("Numeric Test"), "{families:?}");
|
||||
for family in ["WenQuanYi Micro Hei", "Geeza Pro"] {
|
||||
assert!(db.fallback_stack().any(|f| f == family), "{family} in {:?}", db.fallback_stack().collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each face of a craft font collection keeps the family its `name` table gives.
|
||||
#[test]
|
||||
fn craft_font_collections_keep_their_face_families() {
|
||||
let faces = [localized_font(&INTER_REGULAR, "Craft Test A", "クラフト A"), localized_font(&INTER_SEMIBOLD, "Craft Test B", "クラフト B")];
|
||||
let fonts = [craft("Craft Test Collection", &["Jpan", "Latn"], collection(&faces))];
|
||||
let mut db = FontDb::new();
|
||||
db.register_craft_fonts(&fonts);
|
||||
assert!(db.has_family("Craft Test A") && db.has_family("Craft Test B"), "{:?}", db.families());
|
||||
assert!(!db.has_family("Craft Test Collection"));
|
||||
}
|
||||
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
//! Font database: bundled fonts (always available, also on the web), the optional craft-fonts
|
||||
//! Japanese fonts ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional system
|
||||
//! fonts found by scanning platform directories (plus Fontconfig's file list on Linux),
|
||||
//! fonts of every script ([`crate::craft_fonts`], when built with `CRAFT_FONTS_DIR`), optional
|
||||
//! system fonts found by scanning platform directories (plus Fontconfig's file list on Linux),
|
||||
//! user-registered font data, and PostScript-name lookup for PSD import.
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -225,14 +225,22 @@ impl FontDb {
|
||||
db.register_static_font(bytes.as_slice());
|
||||
}
|
||||
// The optional craft-fonts (empty unless built with CRAFT_FONTS_DIR; always empty on
|
||||
// wasm32), before any system font.
|
||||
for f in crate::craft_fonts::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) {
|
||||
db.register_static_font(f.bytes);
|
||||
}
|
||||
// wasm32), every script, before any system font.
|
||||
db.register_craft_fonts(crate::craft_fonts::CRAFT_FONTS);
|
||||
db.refresh_generics();
|
||||
db
|
||||
}
|
||||
|
||||
/// Registers craft-fonts manifest entries of every script. A single-face file registers under
|
||||
/// its manifest family, whatever family its `name` table gives. Each face of a collection
|
||||
/// (TTC/OTC) keeps the family its `name` table gives.
|
||||
fn register_craft_fonts(&mut self, fonts: &[crate::CraftFont]) {
|
||||
for f in fonts {
|
||||
let family = (face_count(f.bytes) == 1).then_some(f.family);
|
||||
self.register_blob(Blob::new(Arc::new(f.bytes)), family);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled fonts plus the fonts installed on this machine (no-op on the web).
|
||||
pub fn with_system_fonts() -> Self {
|
||||
let mut db = Self::new();
|
||||
@@ -279,16 +287,18 @@ impl FontDb {
|
||||
|
||||
/// Registers font data (TTF/OTF, or every face of a TTC/OTC). Returns the family names added.
|
||||
pub fn register_font_data(&mut self, bytes: Vec<u8>) -> Vec<String> {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)))
|
||||
self.register_blob(Blob::new(Arc::new(bytes)), None)
|
||||
}
|
||||
|
||||
/// Registers embedded font data without copying it.
|
||||
fn register_static_font(&mut self, bytes: &'static [u8]) -> Vec<String> {
|
||||
self.register_blob(Blob::new(Arc::new(bytes)))
|
||||
self.register_blob(Blob::new(Arc::new(bytes)), None)
|
||||
}
|
||||
|
||||
fn register_blob(&mut self, blob: Blob<u8>) -> Vec<String> {
|
||||
let added = self.fcx.collection.register_fonts(blob, None);
|
||||
/// Registers every face in `blob`, under `family` when given, else under the family each
|
||||
/// face's `name` table gives.
|
||||
fn register_blob(&mut self, blob: Blob<u8>, family: Option<&str>) -> Vec<String> {
|
||||
let added = self.fcx.collection.register_fonts(blob, family.map(|f| FontInfoOverride { family_name: Some(f), ..Default::default() }));
|
||||
let mut names = Vec::new();
|
||||
for (id, _) in added {
|
||||
if let Some(n) = self.fcx.collection.family_name(id)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list,
|
||||
//! user font data, TrueType collections, PostScript-name lookup. Builds made with the optional
|
||||
//! craft-fonts input
|
||||
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its Japanese fonts as fallbacks.
|
||||
//! (`CRAFT_FONTS_DIR`, see [`craft_fonts`]) also carry its fonts of every script (a single-face
|
||||
//! file under its manifest family name); the ones the fallback lists name are fallbacks.
|
||||
//! * [`layout`]: shaping and line layout with [parley] (HarfRust shaping, bidi, line breaking),
|
||||
//! point and paragraph (box) text, per-run styles, Photoshop leading/indent/spacing rules.
|
||||
//! * [`render`]: anti-aliased rasterization (exact-area accumulation, f32 coverage) into a
|
||||
|
||||
+19
-11
@@ -76,8 +76,13 @@ pub struct Coverage {
|
||||
}
|
||||
|
||||
impl Coverage {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self { width, height, acc: vec![0.0; width * height + 2] }
|
||||
/// A zeroed accumulator, or `None` when its bytes cannot be reserved.
|
||||
pub fn try_new(width: usize, height: usize) -> Option<Self> {
|
||||
let n = width.checked_mul(height)?.checked_add(2)?;
|
||||
let mut acc = Vec::new();
|
||||
acc.try_reserve_exact(n).ok()?;
|
||||
acc.resize(n, 0.0);
|
||||
Some(Self { width, height, acc })
|
||||
}
|
||||
|
||||
/// Adds a line segment (pixel coordinates, y down).
|
||||
@@ -148,15 +153,18 @@ impl Coverage {
|
||||
}
|
||||
|
||||
/// Resolves the accumulation into per-pixel coverage in 0..=1.
|
||||
pub fn finish(self) -> Vec<f32> {
|
||||
/// The accumulated coverage, or `None` when the output cannot be reserved. The input
|
||||
/// accumulator is still live while this builds, so both count toward a caller's budget.
|
||||
pub fn finish(self) -> Option<Vec<f32>> {
|
||||
let n = self.width * self.height;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(n).ok()?;
|
||||
let mut sum = 0.0f32;
|
||||
for v in &self.acc[..n] {
|
||||
sum += v;
|
||||
out.push(sum.abs().min(1.0));
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,9 +248,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn square_area_exact() {
|
||||
let mut c = Coverage::new(10, 10);
|
||||
let mut c = Coverage::try_new(10, 10).unwrap();
|
||||
rect_to(&mut c, &Xform::IDENTITY, 2.25, 3.5, 6.75, 8.0);
|
||||
let v = c.finish();
|
||||
let v = c.finish().unwrap();
|
||||
assert!((sum(&v) - 4.5 * 4.5).abs() < 1e-3, "{}", sum(&v));
|
||||
assert_eq!(v[5 * 10 + 4], 1.0);
|
||||
assert_eq!(v[0], 0.0);
|
||||
@@ -250,13 +258,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn triangle_area_and_clipping() {
|
||||
let mut c = Coverage::new(8, 8);
|
||||
let mut c = Coverage::try_new(8, 8).unwrap();
|
||||
// Right triangle partly outside on the left and bottom.
|
||||
let pts = [(-4.0, 2.0), (6.0, 2.0), (-4.0, 12.0)];
|
||||
for i in 0..3 {
|
||||
c.line(pts[i], pts[(i + 1) % 3]);
|
||||
}
|
||||
let v = c.finish();
|
||||
let v = c.finish().unwrap();
|
||||
// Visible part: region x in [0,6], y in [2,8] under the hypotenuse x + y <= 8.
|
||||
// Area = ∫_{y=2}^{8} (8 - y) dy = 18.
|
||||
assert!((sum(&v) - 18.0).abs() < 1e-3, "{}", sum(&v));
|
||||
@@ -266,7 +274,7 @@ mod tests {
|
||||
#[test]
|
||||
fn circle_area_via_pen() {
|
||||
use skrifa::outline::OutlinePen;
|
||||
let mut c = Coverage::new(40, 40);
|
||||
let mut c = Coverage::try_new(40, 40).unwrap();
|
||||
{
|
||||
let mut p = Pen::new(&mut c, Xform([1.0, 0.0, 0.0, -1.0, 20.0, 20.0]));
|
||||
// Cubic circle approximation, r = 15.
|
||||
@@ -278,7 +286,7 @@ mod tests {
|
||||
p.curve_to(k, -r, r, -k, r, 0.0);
|
||||
p.close();
|
||||
}
|
||||
let a = sum(&c.finish());
|
||||
let a = sum(&c.finish().unwrap());
|
||||
let exact = std::f32::consts::PI * 225.0;
|
||||
assert!((a - exact).abs() / exact < 0.005, "{a} vs {exact}");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,40 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0;
|
||||
pub const FAUX_BOLD_RADIUS: f32 = 0.036;
|
||||
/// Largest raster we produce (pixels), as a guard against absurd sizes.
|
||||
const MAX_PIXELS: u64 = 256 * 1024 * 1024;
|
||||
/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the
|
||||
/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more
|
||||
/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does
|
||||
/// for the same pixels.
|
||||
const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
|
||||
|
||||
/// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the
|
||||
/// output surface (both `stride` deep), plus the coverage buffers live at the peak. A plain pass
|
||||
/// peaks inside `Coverage::finish`, which holds its accumulator and the vector it builds beside
|
||||
/// it; a faux-bold pass peaks one buffer higher, since the resolved plain coverage is kept while
|
||||
/// the bold pass resolves into it. `false` when the arithmetic overflows, so an absurd size is
|
||||
/// refused rather than wrapping.
|
||||
fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool {
|
||||
let f32s = size_of::<f32>() as u64;
|
||||
let peak = (w as u64).checked_mul(h as u64).and_then(|px| {
|
||||
let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?;
|
||||
let coverage = px.checked_mul(if bold { 3 } else { 2 })?.checked_mul(f32s)?;
|
||||
deep.checked_add(coverage)
|
||||
});
|
||||
peak.is_some_and(|b| b <= max_bytes)
|
||||
}
|
||||
|
||||
/// A zeroed buffer, or `None` when its bytes cannot be reserved.
|
||||
fn zeroed(len: usize) -> Option<Vec<f32>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(len).ok()?;
|
||||
v.resize(len, 0.0);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// What a refused or unrenderable layout returns.
|
||||
fn nothing(format: PixelFormat) -> Rendered {
|
||||
Rendered { surface: Surface::new(format), rect: Rect::new(0, 0, 0, 0) }
|
||||
}
|
||||
|
||||
/// Maps raw area-coverage to the opacity Photoshop's anti-alias modes imply. Exponents above 1 thin
|
||||
/// the fringe (sharper); below 1 widen it (stronger smoothing).
|
||||
@@ -277,8 +311,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
}
|
||||
let n = format.mode.color_channels();
|
||||
let stride = n + 1;
|
||||
let bold = layout.glyphs.iter().any(|g| is_bold(layout, g));
|
||||
if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) {
|
||||
return nothing(format);
|
||||
}
|
||||
// Premultiplied accumulation.
|
||||
let mut acc = vec![0.0f32; w * h * stride];
|
||||
let Some(mut acc) = zeroed(w * h * stride) else {
|
||||
return nothing(format);
|
||||
};
|
||||
let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m));
|
||||
let mut colors: Vec<Color> = Vec::new();
|
||||
for st in &layout.styles {
|
||||
@@ -287,14 +327,23 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo
|
||||
}
|
||||
}
|
||||
for c in &colors {
|
||||
let mut cov = Coverage::new(w, h);
|
||||
let Some(mut cov) = Coverage::try_new(w, h) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain);
|
||||
let mut cov = cov.finish();
|
||||
if layout.glyphs.iter().any(|g| is_bold(layout, g)) {
|
||||
let Some(mut cov) = cov.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
if bold {
|
||||
for k in 0..BOLD_OFFSETS.len() {
|
||||
let mut bc = Coverage::new(w, h);
|
||||
let Some(mut bc) = Coverage::try_new(w, h) else {
|
||||
return nothing(format);
|
||||
};
|
||||
draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k));
|
||||
for (a, b) in cov.iter_mut().zip(bc.finish()) {
|
||||
let Some(bold_cov) = bc.finish() else {
|
||||
return nothing(format);
|
||||
};
|
||||
for (a, b) in cov.iter_mut().zip(bold_cov) {
|
||||
*a = a.max(b);
|
||||
}
|
||||
}
|
||||
@@ -445,7 +494,8 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) ->
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_PIXELS, rect_from_bounds};
|
||||
use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed};
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
#[test]
|
||||
@@ -466,4 +516,25 @@ mod tests {
|
||||
assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel
|
||||
/// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers.
|
||||
/// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 2 x 4 = 18 bytes a pixel live.
|
||||
#[test]
|
||||
fn the_byte_budget_follows_channels_depth_and_faux_bold() {
|
||||
let px = 100 * 100;
|
||||
assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18));
|
||||
assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18 - 1), "the allowance is exact");
|
||||
|
||||
// The same pixels cost more with more channels, a wider sample, or a bold pass.
|
||||
assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 18), "CMYK is deeper");
|
||||
assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 18), "f32 output is wider");
|
||||
assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 18), "bold holds one more buffer");
|
||||
assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel");
|
||||
|
||||
// A size whose peak overflows is refused rather than wrapping to a small number.
|
||||
assert!(!fits_raster_budget(usize::MAX, usize::MAX, 5, SampleType::F32, true, u64::MAX));
|
||||
// Nothing is reserved for a buffer that cannot be served.
|
||||
assert!(zeroed(usize::MAX).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1728,10 +1728,17 @@ fn craft_fonts_cover_japanese_without_system_fonts() {
|
||||
let l = e.layout(&point(text, 24.0), 72.0);
|
||||
assert_eq!(l.glyphs.len(), text.chars().count());
|
||||
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts");
|
||||
// Sans (Inter) runs fall back to the Gothic UI family.
|
||||
assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]);
|
||||
// Serif runs fall back to a Mincho face when the build has one (not the web build).
|
||||
if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
|
||||
// Sans (Inter) runs fall back to the first CJK craft family in the UI locale's script order:
|
||||
// the Gothic UI family when Japanese leads that order or the build has no other CJK craft font.
|
||||
let cjk = |f: &crate::CraftFont| f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore"));
|
||||
let first = e.fonts.fallback_stack().find_map(|fam| crate::CRAFT_FONTS.iter().find(|f| f.family == fam && cjk(f))).map(|f| f.family);
|
||||
if !crate::CRAFT_FONTS.iter().any(|f| cjk(f) && !f.is_japanese()) || crate::cjk::ui_script_order()[0] == crate::cjk::CjkScript::Japanese {
|
||||
assert_eq!(first, Some(crate::craft_fonts::UI_JAPANESE_FAMILY));
|
||||
}
|
||||
assert_eq!(craft_family_of(&l), first.into_iter().collect::<Vec<_>>());
|
||||
// Serif runs fall back to a Mincho face when the build has one (not the web build) and the
|
||||
// Japanese craft fonts come first.
|
||||
if first == Some(crate::craft_fonts::UI_JAPANESE_FAMILY) && crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
|
||||
let mut t = point(text, 24.0);
|
||||
t.font_family = "Times New Roman".into();
|
||||
let l = e.layout(&t, 72.0);
|
||||
@@ -1744,6 +1751,23 @@ fn craft_fonts_cover_japanese_without_system_fonts() {
|
||||
assert!(craft_family_of(&l).is_empty());
|
||||
}
|
||||
|
||||
/// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the
|
||||
/// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts).
|
||||
#[test]
|
||||
fn craft_fonts_cover_their_other_scripts_without_system_fonts() {
|
||||
let mut e = TextEngine::new();
|
||||
for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] {
|
||||
if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) {
|
||||
eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
continue;
|
||||
}
|
||||
let l = e.layout(&point(text, 24.0), 72.0);
|
||||
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{script}: no .notdef with craft-fonts");
|
||||
let fams = craft_family_of(&l);
|
||||
assert!(fams.iter().any(|fam| crate::CRAFT_FONTS.iter().any(|f| f.family == *fam && f.scripts.contains(&script))), "{script}: drawn with {fams:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
// Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics
|
||||
|
||||
@@ -110,6 +110,13 @@ impl Warp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The arc length along `path`, measured as type on it measures it (from the end it starts at
|
||||
/// with `flip`), of the point on the path nearest (`x`, `y`): the `start` that puts the type's
|
||||
/// origin where the Type tool clicked on the path. `None` for a path without length.
|
||||
pub fn arc_length_at(path: &Subpath, flip: bool, x: f64, y: f64) -> Option<f64> {
|
||||
PathFrame::new(path, 0.0, flip).map(|f| f.project(x, y))
|
||||
}
|
||||
|
||||
/// Most cubic segments of a type path that are followed (a hostile file cannot make this large).
|
||||
const MAX_PATH_SEGMENTS: usize = 4096;
|
||||
|
||||
@@ -163,6 +170,22 @@ impl PathFrame {
|
||||
self.cum.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Arc length of the point on the flattened path nearest (`x`, `y`).
|
||||
fn project(&self, x: f64, y: f64) -> f64 {
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, w) in self.pts.windows(2).enumerate() {
|
||||
let [a, b] = [w[0], w[1]];
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
let len2 = dx * dx + dy * dy;
|
||||
let t = if len2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = (a.0 + dx * t - x).hypot(a.1 + dy * t - y);
|
||||
if d < best.0 {
|
||||
best = (d, self.cum.get(i).copied().unwrap_or(0.0) + len2.sqrt() * t);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// Point and unit tangent at arc length `s`; past either end the end tangent continues straight.
|
||||
fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) {
|
||||
let last = self.pts.len() - 1;
|
||||
@@ -211,6 +234,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Type tool's click on a path: the nearest point's arc length, from the other end with flip.
|
||||
#[test]
|
||||
fn arc_length_at_projects_onto_the_path() {
|
||||
let line = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 50.0)]);
|
||||
let at = |flip, x, y| arc_length_at(&line, flip, x, y).unwrap();
|
||||
assert!((at(false, 30.0, -8.0) - 30.0).abs() < 1e-6);
|
||||
assert!((at(false, 110.0, 20.0) - 120.0).abs() < 1e-6, "on the second segment");
|
||||
assert!((at(true, 30.0, -8.0) - 120.0).abs() < 1e-6, "flipped: measured from the far end");
|
||||
assert!(at(false, -50.0, 0.0).abs() < 1e-6, "clamped to the start");
|
||||
assert!(arc_length_at(&Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), false, 0.0, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_resolve_and_none_is_identity() {
|
||||
assert_eq!(psd_style("Arc"), Some("warpArc"));
|
||||
|
||||
@@ -4412,6 +4412,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Photoshop's Move tool keeps the standard arrow pointer (#5646): the movable
|
||||
// layer under it is marked by the hover outline, not by a different cursor.
|
||||
Tool::Move => egui::CursorIcon::Default,
|
||||
// Over a path it can follow, the Type tool's path-type pointer (Photoshop).
|
||||
Tool::Type
|
||||
if app.drag.is_none() && {
|
||||
let [x, y] = xf.to_doc(p);
|
||||
crate::type_tool::path_at(app, x, y).is_some()
|
||||
} =>
|
||||
{
|
||||
crate::type_tool::path_cursor(&painter, p)
|
||||
}
|
||||
// Dragging is handled with the pan itself, above.
|
||||
Tool::Hand => crate::tool_cursor::hand(ui.ctx(), false),
|
||||
Tool::RotateView => crate::rotate_view::cursor(response.dragged()),
|
||||
@@ -5453,7 +5462,7 @@ pub(crate) fn composite_color(app: &mut PhotocraftApp, x: f64, y: f64) -> Option
|
||||
fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui::Modifiers) {
|
||||
let tool = app.active_tool();
|
||||
if tool.is_type() && app.drag.is_none() {
|
||||
crate::type_tool::pointer_move(app, x, y);
|
||||
crate::type_tool::pointer_move(app, x, y, mods);
|
||||
}
|
||||
if tool == Tool::Pen && mods.alt {
|
||||
crate::vector_ui::pen_break_handle(app);
|
||||
|
||||
@@ -1804,6 +1804,12 @@ mod tests {
|
||||
assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap());
|
||||
assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
|
||||
/// params, choices and title in every UI language, not just Korean.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech,
|
||||
Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0,
|
||||
at your option.
|
||||
Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR
|
||||
Apache-2.0, at your option.
|
||||
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
|
||||
|
||||
Korean translations: PhotoCraft contributors, original translations of
|
||||
@@ -37,3 +37,6 @@ PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Serbian translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
Russian translations: PhotoCraft contributors, original translations of
|
||||
PhotoCraft's English labels; no proprietary translation resources were used.
|
||||
|
||||
@@ -1499,6 +1499,7 @@
|
||||
Set shape fill type Nastavit typ výplně tvaru
|
||||
Set shape stroke type Nastavit typ tahu tvaru
|
||||
Set the text color Nastavit barvu textu
|
||||
Create warped text Vytvořit deformovaný text
|
||||
Shadows Stíny
|
||||
Shape: Tvar:
|
||||
Sharpening Doostření
|
||||
@@ -2382,11 +2383,11 @@ cameraRaw Color Barvy
|
||||
Apply At Canvas Bounds Použít efekt na hranicích plátna
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Výběr barvy (Barva textu)
|
||||
Color Picker (Text Color) Výběr barvy (barva textu)
|
||||
Color Picker ({name}) Výběr barvy ({name})
|
||||
Color Picker (Fill Color) Výběr barvy (Barva výplně)
|
||||
Color Picker (Fill Color) Výběr barvy (barva výplně)
|
||||
Fill color Barva výplně
|
||||
Color Picker (Stroke Color) Výběr barvy (Barva tahu)
|
||||
Color Picker (Stroke Color) Výběr barvy (barva tahu)
|
||||
Stroke color Barva tahu
|
||||
Pick screen color Vybrat barvu obrazovky
|
||||
Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce
|
||||
@@ -2538,7 +2539,7 @@ fontWeight Italic Kurzíva
|
||||
Match Canvas Podle plátna
|
||||
Auto Adjust Opacity Automaticky upravit krytí
|
||||
Crop Shield color Barva stínění oříznutí
|
||||
Color Picker (Crop Shield Color) Výběr barvy (Barva stínění oříznutí)
|
||||
Color Picker (Crop Shield Color) Výběr barvy (barva stínění oříznutí)
|
||||
From Center Ze středu
|
||||
Link radii Propojit poloměry
|
||||
Star Ratio: Poměr hvězdy:
|
||||
@@ -2655,7 +2656,7 @@ cameraData Lens Objektiv
|
||||
Search settings… Hledat nastavení…
|
||||
No matching settings Žádná odpovídající nastavení
|
||||
# Edit › Content-Aware Fill workspace
|
||||
Sampling Brush Tool Nástroj Štětec vzorkování
|
||||
Sampling Brush Tool Nástroj štětec vzorkování
|
||||
Sampling Area Overlay Překrytí oblasti vzorkování
|
||||
Show Sampling Area Zobrazit oblast vzorkování
|
||||
Indicates Označuje
|
||||
@@ -3832,7 +3833,7 @@ navigator Zoom Zvětšení
|
||||
Use the embedded profile ({profile}) Použít vložený profil ({profile})
|
||||
Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile})
|
||||
Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky
|
||||
Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev.
|
||||
Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou „Ptát se při nesouladu profilů“ v nabídce Úpravy › Nastavení barev.
|
||||
Button Mode Režim tlačítka
|
||||
Button Mode On Režim tlačítka zapnutý
|
||||
|
||||
@@ -3920,6 +3921,138 @@ navigator Zoom Zvětšení
|
||||
The fastest and the softest, with colour fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech.
|
||||
“{name}” already exists. Replace it? „{name}“ již existuje. Nahradit?
|
||||
The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“.
|
||||
|
||||
# Labels the UI looks up through a variable (tool, glyph-group, Liquify and warp names, command
|
||||
# parameters and choices humanized from their keys, history steps and contextual
|
||||
# Camera Raw / layer labels) that other catalogs already covered. Translated the same way as
|
||||
# above, from the plain meaning of the English label.
|
||||
Export As: {name} Exportovat jako: {name}
|
||||
Tool Nástroj
|
||||
Arc Lower Oblouk dole
|
||||
Arc Upper Oblouk nahoře
|
||||
Arrows Šipky
|
||||
Basic Latin Základní latinka
|
||||
Bloat Nafouknout
|
||||
Burn Ztmavení
|
||||
Checkerboard Šachovnice
|
||||
Clone Stamp Klonovací razítko
|
||||
Color Sampler Tool Nástroj vzorkovač barev
|
||||
Currency Měnové symboly
|
||||
Cyrillic Cyrilice
|
||||
Decrease Type Size Zmenšit velikost písma
|
||||
Decrease Type Size by 10 Zmenšit velikost písma o 10
|
||||
Delete Snapshot Odstranit snímek
|
||||
Detect Faces Rozpoznat obličeje
|
||||
Dodge Zesvětlení
|
||||
Erase with Brush or Pencil (hold) Mazat štětcem nebo tužkou (při podržení klávesy)
|
||||
Fill with Background Color Vyplnit barvou pozadí
|
||||
Fill with Background Color, Preserve Transparency Vyplnit barvou pozadí, zachovat průhlednost
|
||||
Fill with Foreground Color Vyplnit barvou popředí
|
||||
Fill with Foreground Color, Preserve Transparency Vyplnit barvou popředí, zachovat průhlednost
|
||||
Forward Warp Deformovat tažením
|
||||
Freeze Lasso Laso zmrazení
|
||||
Freeze Mask Zmrazit oblast
|
||||
Greek Řecké písmo
|
||||
Hand Tool (hold) Nástroj ruka (při podržení klávesy)
|
||||
History Brush Štětec historie
|
||||
Increase Type Size Zvětšit velikost písma
|
||||
Increase Type Size by 10 Zvětšit velikost písma o 10
|
||||
Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: zvolte „Metrické“, „Optické“ nebo hodnotu v 1/1000 em (Alt+←/→ v místě textového kurzoru)
|
||||
Latin 1 Latinka 1
|
||||
Latin Extended-A Rozšířená latinka A
|
||||
Latin Extended-B Rozšířená latinka B
|
||||
Lock Transparent Pixels Zamknout průhledné pixely
|
||||
Make Selection from Path Vytvořit výběr z cesty
|
||||
Mask None Zrušit maskování
|
||||
Math Symbols Matematické symboly
|
||||
New Snapshot Vytvořit snímek
|
||||
Numbers Čísla
|
||||
Pattern Fill… Výplň vzorkem…
|
||||
Pick Color From Image Vybrat barvu z obrazu
|
||||
Proof Setup… Nastavení nátisku…
|
||||
Pucker Stáhnout
|
||||
Push Left Tlačit doleva
|
||||
Reconstruct All Rekonstruovat vše
|
||||
Save Path Uložit cestu
|
||||
Sponge Houba
|
||||
Target Channel 3 Zacílit na kanál 3
|
||||
Target Channel 4 Zacílit na kanál 4
|
||||
Target Channel 5 Zacílit na kanál 5
|
||||
Target Channel 6 Zacílit na kanál 6
|
||||
Target Channel 7 Zacílit na kanál 7
|
||||
Target Channel 8 Zacílit na kanál 8
|
||||
Target Channel 9 Zacílit na kanál 9
|
||||
Target Composite Channel Zacílit na složený kanál
|
||||
Temporary Dočasné
|
||||
Thaw Mask Rozmrazit oblast
|
||||
Toggle Layer Mask Overlay Přepnout překryv masky vrstvy
|
||||
Twirl Clockwise Zvířit doprava
|
||||
Twirl Counterclockwise Zvířit doleva
|
||||
Warming Filter (85) Oteplovací filtr (85)
|
||||
Zoom In (hold, drag to scrub) Přiblížit (při podržení klávesy, tažením plynule měníte zvětšení)
|
||||
Zoom Out (hold) Oddálit (při podržení klávesy)
|
||||
blue modrá
|
||||
gray šedá
|
||||
green zelená
|
||||
none žádná
|
||||
orange oranžová
|
||||
red červená
|
||||
violet fialová
|
||||
yellow žlutá
|
||||
Adobe-rgb-compat Kompatibilní s Adobe RGB (1998)
|
||||
Any Bez omezení
|
||||
Ask On Mismatch Ptát se při nesouladu profilů
|
||||
Ask On Missing Ptát se při chybějícím profilu
|
||||
Ask On Paste Ptát se při vkládání
|
||||
Author Title Pracovní pozice autora
|
||||
Based On Podle
|
||||
Blend Text Gamma Gama prolnutí textu
|
||||
Case Sensitive Rozlišovat velikost písmen
|
||||
Ccw Proti směru hodinových ručiček
|
||||
Change Find Změnit a najít další
|
||||
Cmyk CMYK
|
||||
Coated-cmyk CMYK pro křídový papír
|
||||
Convert Převést
|
||||
Copyright Url URL informací o autorských právech
|
||||
Cw Po směru hodinových ručiček
|
||||
Display-p3 Display P3
|
||||
Dont Enlarge Nezvětšovat
|
||||
Forward Vpřed
|
||||
Horizontal Distortion Vodorovné zkreslení
|
||||
Intent Záměr převodu
|
||||
Linear-srgb Lineární sRGB
|
||||
Mask Channel Kanál masky
|
||||
Monitor Profile Profil monitoru
|
||||
Nearest Nejbližší soused
|
||||
Policy Cmyk Zásady správy barev pro CMYK
|
||||
Policy Gray Zásady správy barev pro šedou
|
||||
Policy Rgb Zásady správy barev pro RGB
|
||||
Prophoto-compat Kompatibilní s ProPhoto
|
||||
R G B RGB
|
||||
Rec2020 Rec. 2020
|
||||
Sgray sGray (tónová křivka sRGB)
|
||||
Simulate Paper Simulovat papír
|
||||
Source Channel Zdrojový kanál
|
||||
Source Invert Invertovat zdroj
|
||||
Source1 Channel Kanál zdroje 1
|
||||
Source1 Invert Invertovat zdroj 1
|
||||
Source2 Channel Kanál zdroje 2
|
||||
Source2 Invert Invertovat zdroj 2
|
||||
Title Název
|
||||
Vertical Distortion Svislé zkreslení
|
||||
Whole Word Celé slovo
|
||||
Working Cmyk Pracovní CMYK
|
||||
Working Gray Pracovní šedá
|
||||
Working Rgb Pracovní RGB
|
||||
Working-cmyk Pracovní CMYK
|
||||
Warming Filter (LBA) Oteplovací filtr (LBA)
|
||||
Warming Filter (81) Oteplovací filtr (81)
|
||||
Cooling Filter (80) Ochlazovací filtr (80)
|
||||
Cooling Filter (LBB) Ochlazovací filtr (LBB)
|
||||
Cooling Filter (82) Ochlazovací filtr (82)
|
||||
layers Fill Krytí výplně
|
||||
layers Fill: Krytí výplně:
|
||||
cameraRaw Dehaze Odstranění oparu
|
||||
Memory Usage Využití paměti
|
||||
Per-document RAM limit (MB) Limit RAM na dokument (MB)
|
||||
Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}%
|
||||
@@ -3950,3 +4083,8 @@ navigator Zoom Zvětšení
|
||||
Resize Windows to Fit Přizpůsobit velikost oken
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatický směr odstavce
|
||||
Left-to-right paragraph direction Směr odstavce zleva doprava
|
||||
Right-to-left paragraph direction Směr odstavce zprava doleva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1482,6 +1482,7 @@
|
||||
Set shape fill type Fülltyp der Form festlegen
|
||||
Set shape stroke type Konturtyp der Form festlegen
|
||||
Set the text color Textfarbe festlegen
|
||||
Create warped text Verkrümmten Text erstellen
|
||||
Shadows Tiefen
|
||||
Shape: Form:
|
||||
Sharpening Scharfzeichnung
|
||||
@@ -3348,3 +3349,8 @@ curves Smooth Glätten
|
||||
Resize Windows to Fit Fenstergröße anpassen
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatische Absatzrichtung
|
||||
Left-to-right paragraph direction Absatzrichtung von links nach rechts
|
||||
Right-to-left paragraph direction Absatzrichtung von rechts nach links
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3046,6 +3046,7 @@
|
||||
Set shape fill type Ορισμός τύπου γεμίσματος σχήματος
|
||||
Set shape stroke type Ορισμός τύπου περιγράμματος σχήματος
|
||||
Set the text color Ορισμός χρώματος κειμένου
|
||||
Create warped text Δημιουργία παραμορφωμένου κειμένου
|
||||
Shadows/Highlights Σκιές/φωτεινές περιοχές
|
||||
Shape Σχήμα
|
||||
Shape Blur Θόλωση σχήματος
|
||||
@@ -4038,3 +4039,8 @@ curves Smooth Εξομάλυνση
|
||||
Resize Windows to Fit Προσαρμογή μεγέθους παραθύρων
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Αυτόματη κατεύθυνση παραγράφου
|
||||
Left-to-right paragraph direction Κατεύθυνση παραγράφου από αριστερά προς τα δεξιά
|
||||
Right-to-left paragraph direction Κατεύθυνση παραγράφου από δεξιά προς τα αριστερά
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -1489,6 +1489,7 @@
|
||||
Set shape fill type Establecer el tipo de relleno de la forma
|
||||
Set shape stroke type Establecer el tipo de trazo de la forma
|
||||
Set the text color Establecer el color del texto
|
||||
Create warped text Crear texto deformado
|
||||
Shadows Sombras
|
||||
Shape: Forma:
|
||||
Sharpening Enfoque
|
||||
@@ -4300,3 +4301,8 @@ navigator Zoom Zoom
|
||||
Resize Windows to Fit Redimensionar ventanas para ajustar
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Dirección de párrafo automática
|
||||
Left-to-right paragraph direction Dirección de párrafo de izquierda a derecha
|
||||
Right-to-left paragraph direction Dirección de párrafo de derecha a izquierda
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
# Hindi (hi) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
|
||||
#
|
||||
# context empty a plain UI string; `source` is the English text in the code
|
||||
# @id a command id (e.g. file.saveAs): wins over the English text for
|
||||
# that menu item, so one id can read differently from its twin
|
||||
# @plural `source` is "one|other" (English forms); the translation lists the
|
||||
# language's forms separated by "|" (see `plural` in i18n/mod.rs)
|
||||
# anything else a disambiguating context passed to `tr_ctx`
|
||||
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns.
|
||||
# lines starting with # and blank lines are ignored.
|
||||
#
|
||||
# Clean-room: every entry was written from the plain meaning of the English label using ordinary
|
||||
# Hindi computing and image-editing vocabulary. No proprietary localisation resource, string
|
||||
# table or product UI was consulted or copied; add entries the same way. Product and technology
|
||||
# names (PhotoCraft, ArtCraft, OpenType, RGB, CMYK, Lab, PSD, PNG, JPEG, WebP, OpenEXR, AVIF …)
|
||||
# stay as they are; the trailing "…" of dialog commands is kept. Plural rule: one / other
|
||||
# (`plural_one_other` in i18n/mod.rs).
|
||||
|
||||
# Top-level Menus
|
||||
File फ़ाइल
|
||||
Edit एडिट
|
||||
Image इमेज
|
||||
Layer लेयर
|
||||
Type टाइप
|
||||
Select चयन
|
||||
Filter फ़िल्टर
|
||||
3D 3डी
|
||||
View व्यू
|
||||
Window विंडो
|
||||
Help सहायता
|
||||
|
||||
# File Menu
|
||||
New… नया…
|
||||
Open… खोलें…
|
||||
Open Recent हाल ही में खोले गए
|
||||
Close बंद करें
|
||||
Close All सभी बंद करें
|
||||
Close Others अन्य सभी बंद करें
|
||||
Save सहेजें
|
||||
Save As… इस रूप में सहेजें…
|
||||
Save a Copy… एक प्रति सहेजें…
|
||||
Revert मूल स्थिति में लाएं
|
||||
Export एक्सपोर्ट करें
|
||||
Export As… इस रूप में एक्सपोर्ट करें…
|
||||
Quick Export as PNG PNG के रूप में त्वरित एक्सपोर्ट
|
||||
Import इम्पोर्ट करें
|
||||
File Info… फ़ाइल जानकारी…
|
||||
Print… प्रिंट करें…
|
||||
Print One Copy एक प्रति प्रिंट करें
|
||||
Exit बाहर निकलें
|
||||
|
||||
# Edit Menu
|
||||
Undo पूर्ववत करें
|
||||
Redo फिर से करें
|
||||
Cut काटें
|
||||
Copy कॉपी करें
|
||||
Copy Merged मर्ज की गई कॉपी
|
||||
Paste पेस्ट करें
|
||||
Paste Special विशेष रूप में पेस्ट करें
|
||||
Paste in Place स्थान पर पेस्ट करें
|
||||
Paste Into अंदर पेस्ट करें
|
||||
Paste Outside बाहर पेस्ट करें
|
||||
Clear साफ़ करें
|
||||
Fill… भरें…
|
||||
Stroke… स्ट्रोक…
|
||||
Free Transform मुक्त रूपांतरण
|
||||
Transform रूपांतरण करें
|
||||
Scale आकार बदलें
|
||||
Rotate घुमाएँ
|
||||
Skew तिरछा करें
|
||||
Distort विरूपित करें
|
||||
Perspective परिप्रेक्ष्य
|
||||
Warp मोड़ें
|
||||
Flip Horizontal क्षैतिज पलटें
|
||||
Flip Vertical लंबवत पलटें
|
||||
Preferences प्राथमिकताएं
|
||||
General… सामान्य…
|
||||
Interface… इंटरफ़ेस…
|
||||
Workspace… कार्यस्थान…
|
||||
Tools… टूल्स…
|
||||
Performance… परफॉरमेंस…
|
||||
|
||||
# Image Menu
|
||||
Mode मोड
|
||||
RGB Color RGB रंग
|
||||
CMYK Color CMYK रंग
|
||||
Grayscale ग्रेस्केल
|
||||
Lab Color Lab रंग
|
||||
8 Bits/Channel 8 बिट्स/चैनल
|
||||
16 Bits/Channel 16 बिट्स/चैनल
|
||||
32 Bits/Channel 32 बिट्स/चैनल
|
||||
Adjustments समायोजन
|
||||
Brightness/Contrast… चमक/कंट्रास्ट…
|
||||
Levels… स्तर…
|
||||
Curves… कर्व्स…
|
||||
Exposure… एक्सपोज़र…
|
||||
Vibrance… वाइब्रेंस…
|
||||
Hue/Saturation… ह्यू/सैचुरेशन…
|
||||
Color Balance… कलर बैलेंस…
|
||||
Black & White… ब्लैक एंड व्हाइट…
|
||||
Photo Filter… फ़ोटो फ़िल्टर…
|
||||
Invert इन्वर्ट
|
||||
Posterize… पोस्टराइज़ करें…
|
||||
Threshold… थ्रेशहोल्ड…
|
||||
Image Size… इमेज साइज़…
|
||||
Canvas Size… कैनवास साइज़…
|
||||
Image Rotation इमेज रोटेशन
|
||||
180° 180°
|
||||
90° Clockwise 90° दक्षिणावर्त
|
||||
90° Counter Clockwise 90° वामावर्त
|
||||
Arbitrary… इच्छानुसार…
|
||||
Crop क्रॉप करें
|
||||
Trim… ट्रिम करें…
|
||||
Reveal All सभी दिखाएँ
|
||||
Duplicate… डुप्लीकेट…
|
||||
|
||||
# Layer Menu
|
||||
New नया
|
||||
Layer… लेयर…
|
||||
Layer from Background बैकग्राउंड से लेयर
|
||||
Group… समूह…
|
||||
Group from Layers… लेयर्स से समूह…
|
||||
Artboard… आर्टबोर्ड…
|
||||
Duplicate Layer… लेयर को डुप्लीकेट करें…
|
||||
Delete हटाएँ
|
||||
Delete Layer लेयर हटाएँ
|
||||
Delete Hidden Layers छिपी हुई लेयर्स हटाएँ
|
||||
Rename Layer… लेयर का नाम बदलें…
|
||||
Layer Style लेयर स्टाइल
|
||||
Blending Options… ब्लेंडिंग विकल्प…
|
||||
Smart Objects स्मार्ट ऑब्जेक्ट्स
|
||||
Convert to Smart Object स्मार्ट ऑब्जेक्ट में बदलें
|
||||
Layer Mask लेयर मास्क
|
||||
Hide All सभी छिपाएँ
|
||||
Reveal Selection चयन दिखाएँ
|
||||
Hide Selection चयन छिपाएँ
|
||||
From Transparency पारदर्शिता से
|
||||
Apply लागू करें
|
||||
Enable सक्षम करें
|
||||
Disable अक्षम करें
|
||||
Merge Down नीचे मर्ज करें
|
||||
Merge Visible दृश्यमान मर्ज करें
|
||||
Flatten Image इमेज फ़्लैट करें
|
||||
|
||||
# Select Menu
|
||||
All सभी
|
||||
Deselect अचयनित करें
|
||||
Reselect पुनः चयन करें
|
||||
Inverse उल्टा करें
|
||||
All Layers सभी लेयर्स
|
||||
Deselect Layers लेयर्स अचयनित करें
|
||||
Find Layers लेयर्स खोजें
|
||||
Color Range… रंग सीमा…
|
||||
Select and Mask… चयन और मास्क…
|
||||
Modify संशोधित करें
|
||||
Border… सीमा…
|
||||
Smooth… चिकना करें…
|
||||
Expand… विस्तार करें…
|
||||
Contract… संकुचित करें…
|
||||
Feather… फ़ेदर…
|
||||
Grow बढ़ाएँ
|
||||
Similar समान
|
||||
Transform Selection चयन रूपांतरित करें
|
||||
Load Selection… चयन लोड करें…
|
||||
Save Selection… चयन सहेजें…
|
||||
|
||||
# View Menu
|
||||
Proof Setup प्रूफ़ सेटअप
|
||||
Proof Colors प्रूफ़ रंग
|
||||
Zoom In ज़ूम इन
|
||||
Zoom Out ज़ूम आउट
|
||||
Fit on Screen स्क्रीन पर फ़िट करें
|
||||
Fit Layer on Screen स्क्रीन पर लेयर फ़िट करें
|
||||
Actual Pixels वास्तविक पिक्सेल
|
||||
100% 100%
|
||||
200% 200%
|
||||
Rulers रूलर्स
|
||||
Snap स्नैप
|
||||
Snap To पर स्नैप करें
|
||||
Guides गाइड्स
|
||||
Lock Guides गाइड्स लॉक करें
|
||||
Clear Guides गाइड्स हटाएं
|
||||
New Guide… नई गाइड…
|
||||
Lock Slices स्लाइस लॉक करें
|
||||
Clear Slices स्लाइस हटाएं
|
||||
|
||||
# Window Menu & Panels
|
||||
Arrange व्यवस्थित करें
|
||||
Tile Horizontally क्षैतिज टाइल
|
||||
Tile Vertically लंबवत टाइल
|
||||
Consolidate All to Tabs सभी को टैब में समेकित करें
|
||||
Workspace कार्यस्थान
|
||||
Reset Essentials एसेंशियल्स रीसेट करें
|
||||
New Workspace… नया कार्यस्थान…
|
||||
Delete Workspace… कार्यस्थान हटाएँ…
|
||||
Lock Workspace कार्यस्थान लॉक करें
|
||||
Color रंग
|
||||
Swatches स्वॉचेस
|
||||
Gradients ग्रेडिएंट्स
|
||||
Patterns पैटर्न्स
|
||||
Properties गुण
|
||||
Layers लेयर्स
|
||||
Channels चैनल्स
|
||||
Paths पाथ्स
|
||||
History हिस्ट्री
|
||||
Actions एक्शन्स
|
||||
Brushes ब्रश
|
||||
Brush Settings ब्रश सेटिंग्स
|
||||
Navigator नेविगेटर
|
||||
Info जानकारी
|
||||
Timeline टाइमलाइन
|
||||
Tools टूल्स
|
||||
Options विकल्प
|
||||
Status Bar स्टेटस बार
|
||||
|
||||
# Tools (Toolbox)
|
||||
Move Tool मूव टूल
|
||||
Artboard Tool आर्टबोर्ड टूल
|
||||
Rectangular Marquee Tool रेक्टेंगुलर मार्की टूल
|
||||
Elliptical Marquee Tool एलिप्टिकल मार्की टूल
|
||||
Single Row Marquee Tool सिंगल रो मार्की टूल
|
||||
Single Column Marquee Tool सिंगल कॉलम मार्की टूल
|
||||
Lasso Tool लैस्सो टूल
|
||||
Polygonal Lasso Tool पॉलीगोनल लैस्सो टूल
|
||||
Magnetic Lasso Tool मैग्नेटिक लैस्सो टूल
|
||||
Object Selection Tool ऑब्जेक्ट चयन टूल
|
||||
Quick Selection Tool क्विक चयन टूल
|
||||
Magic Wand Tool मैजिक वैंड टूल
|
||||
Crop Tool क्रॉप टूल
|
||||
Perspective Crop Tool परस्पेक्टिव क्रॉप टूल
|
||||
Slice Tool स्लाइस टूल
|
||||
Slice Select Tool स्लाइस चयन टूल
|
||||
Frame Tool फ़्रेम टूल
|
||||
Eyedropper Tool आईड्रॉपर टूल
|
||||
Color Sampler Tool कलर सैंपलर टूल
|
||||
Ruler Tool रूलर टूल
|
||||
Note Tool नोट टूल
|
||||
Count Tool काउंट टूल
|
||||
Spot Healing Brush Tool स्पॉट हीलिंग ब्रश टूल
|
||||
Healing Brush Tool हीलिंग ब्रश टूल
|
||||
Patch Tool पैच टूल
|
||||
Content-Aware Move Tool कंटेंट-अवेयर मूव टूल
|
||||
Red Eye Tool रेड आई टूल
|
||||
Brush Tool ब्रश टूल
|
||||
Pencil Tool पेंसिल टूल
|
||||
Color Replacement Tool कलर रिप्लेसमेंट टूल
|
||||
Mixer Brush Tool मिक्सर ब्रश टूल
|
||||
Clone Stamp Tool क्लोन स्टैम्प टूल
|
||||
Pattern Stamp Tool पैटर्न स्टैम्प टूल
|
||||
History Brush Tool हिस्ट्री ब्रश टूल
|
||||
Art History Brush Tool आर्ट हिस्ट्री ब्रश टूल
|
||||
Eraser Tool इरेज़र टूल
|
||||
Background Eraser Tool बैकग्राउंड इरेज़र टूल
|
||||
Magic Eraser Tool मैजिक इरेज़र टूल
|
||||
Gradient Tool ग्रेडिएंट टूल
|
||||
Paint Bucket Tool पेंट बकेट टूल
|
||||
Blur Tool ब्लर टूल
|
||||
Sharpen Tool शार्पन टूल
|
||||
Smudge Tool स्मज टूल
|
||||
Dodge Tool डॉज टूल
|
||||
Burn Tool बर्न टूल
|
||||
Sponge Tool स्पंज टूल
|
||||
Pen Tool पेन टूल
|
||||
Freeform Pen Tool फ्रीफॉर्म पेन टूल
|
||||
Curvature Pen Tool कर्वचर पेन टूल
|
||||
Add Anchor Point Tool ऐड एंकर पॉइंट टूल
|
||||
Delete Anchor Point Tool डिलीट एंकर पॉइंट टूल
|
||||
Convert Point Tool कन्वर्ट पॉइंट टूल
|
||||
Horizontal Type Tool हॉरिजोंटल टाइप टूल
|
||||
Vertical Type Tool वर्टिकल टाइप टूल
|
||||
Horizontal Type Mask Tool हॉरिजोंटल टाइप मास्क टूल
|
||||
Vertical Type Mask Tool वर्टिकल टाइप मास्क टूल
|
||||
Path Selection Tool पाथ चयन टूल
|
||||
Direct Selection Tool डायरेक्ट चयन टूल
|
||||
Rectangle Tool रेक्टेंगल टूल
|
||||
Rounded Rectangle Tool राउंडेड रेक्टेंगल टूल
|
||||
Ellipse Tool एलिप्स टूल
|
||||
Triangle Tool ट्रायंगल टूल
|
||||
Polygon Tool पॉलीगोन टूल
|
||||
Line Tool लाइन टूल
|
||||
Custom Shape Tool कस्टम शेप टूल
|
||||
Hand Tool हैंड टूल
|
||||
Rotate View Tool रोटेट व्यू टूल
|
||||
Zoom Tool ज़ूम टूल
|
||||
|
||||
# Common Dialogs and Buttons
|
||||
OK ठीक है
|
||||
Cancel रद्द करें
|
||||
Reset रीसेट करें
|
||||
Done संपन्न
|
||||
Don't Save सहेजें नहीं
|
||||
Yes हाँ
|
||||
No नहीं
|
||||
Width: चौड़ाई:
|
||||
Height: ऊंचाई:
|
||||
Resolution: रिज़ॉल्यूशन:
|
||||
Pixels पिक्सेल
|
||||
Inches इंच
|
||||
Centimeters सेंटीमीटर
|
||||
Millimeters मिलीमीटर
|
||||
Points पॉइंट्स
|
||||
Percent प्रतिशत
|
||||
Opacity: ओपेसिटी:
|
||||
Flow: फ्लो:
|
||||
Hardness: हार्डनेस:
|
||||
Size: साइज़:
|
||||
Angle: एंगल:
|
||||
Roundness: राउंडनेस:
|
||||
Spacing: स्पेसिंग:
|
||||
Color: कलर:
|
||||
Name: नेम:
|
||||
Presets: प्रीसेट्स:
|
||||
Language भाषा
|
||||
Auto स्वचालित
|
||||
|
||||
# Plurals
|
||||
@plural {n} layer|{n} layers {n} लेयर|{n} लेयर्स
|
||||
@plural {n} item|{n} items {n} आइटम|{n} आइटम्स
|
||||
@plural {n} document|{n} documents {n} दस्तावेज़|{n} दस्तावेज़
|
||||
@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल्स
|
||||
|
||||
# Tool and Scope strings
|
||||
Scope दायरा
|
||||
Selected चयनित
|
||||
Mixer Brush मिक्सर ब्रश
|
||||
Wet गीला
|
||||
Load लोड
|
||||
Mix मिश्रण
|
||||
Flow प्रवाह
|
||||
Sample All Layers सभी लेयर्स का नमूना लें
|
||||
Pattern Stamp पैटर्न स्टैम्प
|
||||
Impressionist प्रभाववादी
|
||||
Aligned संरेखित
|
||||
Fit in View दृश्य में फ़िट करें
|
||||
Camera Raw Camera Raw
|
||||
|
||||
# Camera Raw
|
||||
cameraRaw Amount मात्रा
|
||||
Amount मात्रा
|
||||
cameraRaw Aqua एक्वा
|
||||
Aqua एक्वा
|
||||
cameraRaw Balance संतुलन
|
||||
Balance संतुलन
|
||||
cameraRaw Before (Y) पहले (Y)
|
||||
Before (Y) पहले (Y)
|
||||
cameraRaw Blacks ब्लैक्स
|
||||
Blacks ब्लैक्स
|
||||
cameraRaw Blending मिश्रण
|
||||
Blending मिश्रण
|
||||
cameraRaw Blue नीला
|
||||
Blue नीला
|
||||
cameraRaw Camera Raw ({name}) Camera Raw ({name})
|
||||
cameraRaw Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer})
|
||||
Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer})
|
||||
cameraRaw Camera Raw isn't open Camera Raw खुला नहीं है
|
||||
Camera Raw isn't open Camera Raw खुला नहीं है
|
||||
cameraRaw Cancel रद्द करें
|
||||
cameraRaw Cancelled opening {name} {name} खोलना रद्द किया गया
|
||||
Cancelled opening {name} {name} खोलना रद्द किया गया
|
||||
cameraRaw Cancelled {label} {label} रद्द किया गया
|
||||
Cancelled {label} {label} रद्द किया गया
|
||||
cameraRaw Clarity स्पष्टता
|
||||
Clarity स्पष्टता
|
||||
cameraRaw Color रंग
|
||||
cameraRaw Color Detail रंग विवरण
|
||||
Color Detail रंग विवरण
|
||||
cameraRaw Color Grading कलर ग्रेडिंग
|
||||
Color Grading कलर ग्रेडिंग
|
||||
cameraRaw Color Mixer कलर मिक्सर
|
||||
Color Mixer कलर मिक्सर
|
||||
cameraRaw Color Priority रंग प्राथमिकता
|
||||
Color Priority रंग प्राथमिकता
|
||||
cameraRaw Contrast कंट्रास्ट
|
||||
Contrast कंट्रास्ट
|
||||
cameraRaw Curve कर्व
|
||||
Curve कर्व
|
||||
cameraRaw Darks डार्क्स
|
||||
Darks डार्क्स
|
||||
cameraRaw Dehaze डीहेज़
|
||||
Dehaze डीहेज़
|
||||
cameraRaw Detail डिटेल
|
||||
Detail डिटेल
|
||||
cameraRaw Developing {name} {name} विकसित किया जा रहा है
|
||||
Developing {name} {name} विकसित किया जा रहा है
|
||||
cameraRaw Effects इफ़ेक्ट्स
|
||||
Effects इफ़ेक्ट्स
|
||||
cameraRaw Exposure एक्सपोज़र
|
||||
Exposure एक्सपोज़र
|
||||
cameraRaw Feather फ़ेदर
|
||||
Feather फ़ेदर
|
||||
cameraRaw Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है
|
||||
Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है
|
||||
cameraRaw Global ग्लोबल
|
||||
Global ग्लोबल
|
||||
cameraRaw Grain ग्रेन
|
||||
Grain ग्रेन
|
||||
cameraRaw Green हरा
|
||||
Green हरा
|
||||
cameraRaw Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O)
|
||||
Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O)
|
||||
cameraRaw Highlight Priority हाइलाइट प्राथमिकता
|
||||
Highlight Priority हाइलाइट प्राथमिकता
|
||||
cameraRaw Highlights हाइलाइट्स
|
||||
Highlights हाइलाइट्स
|
||||
cameraRaw Hue ह्यू
|
||||
Hue ह्यू
|
||||
cameraRaw Light लाइट
|
||||
Light लाइट
|
||||
cameraRaw Lights लाइट्स
|
||||
Lights लाइट्स
|
||||
cameraRaw Load Settings… सेटिंग्स लोड करें…
|
||||
Load Settings… सेटिंग्स लोड करें…
|
||||
cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें
|
||||
Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें
|
||||
cameraRaw Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं
|
||||
Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं
|
||||
cameraRaw Luminance लुमिनेंस
|
||||
Luminance लुमिनेंस
|
||||
cameraRaw Luminance Detail लुमिनेंस डिटेल
|
||||
Luminance Detail लुमिनेंस डिटेल
|
||||
cameraRaw Magenta मैजेंटा
|
||||
Magenta मैजेंटा
|
||||
cameraRaw Masking मास्किंग
|
||||
Masking मास्किंग
|
||||
cameraRaw Midpoint मिडपॉइंट
|
||||
Midpoint मिडपॉइंट
|
||||
cameraRaw Midtones मिडटोन्स
|
||||
Midtones मिडटोन्स
|
||||
cameraRaw Noise Reduction नॉइज़ रिडक्शन
|
||||
Noise Reduction नॉइज़ रिडक्शन
|
||||
cameraRaw OK ठीक है
|
||||
cameraRaw Open खोलें
|
||||
Open खोलें
|
||||
cameraRaw Opened {name} {name} खोला गया
|
||||
Opened {name} {name} खोला गया
|
||||
cameraRaw Orange नारंगी
|
||||
Orange नारंगी
|
||||
cameraRaw Paint Overlay पेंट ओवरले
|
||||
Paint Overlay पेंट ओवरले
|
||||
cameraRaw Purple बैंगनी
|
||||
Purple बैंगनी
|
||||
cameraRaw Radius त्रिज्या
|
||||
Radius त्रिज्या
|
||||
cameraRaw Red लाल
|
||||
Red लाल
|
||||
cameraRaw Roughness रफनेस
|
||||
Roughness रफनेस
|
||||
cameraRaw Roundness राउंडनेस
|
||||
Roundness राउंडनेस
|
||||
cameraRaw Saturation सैचुरेशन
|
||||
Saturation सैचुरेशन
|
||||
cameraRaw Save Settings… सेटिंग्स सहेजें…
|
||||
Save Settings… सेटिंग्स सहेजें…
|
||||
cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें
|
||||
Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें
|
||||
cameraRaw Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U)
|
||||
Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U)
|
||||
cameraRaw Shadows शेडोज़
|
||||
Shadows शेडोज़
|
||||
cameraRaw Sharpening शार्पनिंग
|
||||
Sharpening शार्पनिंग
|
||||
cameraRaw Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ
|
||||
Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ
|
||||
cameraRaw Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ
|
||||
Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ
|
||||
cameraRaw Show Selected Region चयनित क्षेत्र दिखाएँ
|
||||
Show Selected Region चयनित क्षेत्र दिखाएँ
|
||||
cameraRaw Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ
|
||||
Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ
|
||||
cameraRaw Show Vectorscope वेक्टरस्कोप दिखाएँ
|
||||
Show Vectorscope वेक्टरस्कोप दिखाएँ
|
||||
cameraRaw Size आकार
|
||||
Size आकार
|
||||
cameraRaw Temperature तापमान
|
||||
Temperature तापमान
|
||||
cameraRaw Texture टेक्सचर
|
||||
Texture टेक्सचर
|
||||
cameraRaw Tint टिंट
|
||||
Tint टिंट
|
||||
cameraRaw Tone Histogram टोन हिस्टोग्राम
|
||||
Tone Histogram टोन हिस्टोग्राम
|
||||
cameraRaw Vibrance वाइब्रेंस
|
||||
Vibrance वाइब्रेंस
|
||||
cameraRaw Vignetting विगनेटिंग
|
||||
Vignetting विगनेटिंग
|
||||
cameraRaw White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार
|
||||
White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार
|
||||
cameraRaw Whites व्हाइट्स
|
||||
Whites व्हाइट्स
|
||||
cameraRaw Yellow पीला
|
||||
Yellow पीला
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1306,6 +1306,7 @@
|
||||
Set painting symmetry options Festési szimmetria beállításai
|
||||
Set the overlay options for the Crop tool A Körbevágás eszköz fedvénybeállításai
|
||||
Set the text color Szövegszín beállítása
|
||||
Create warped text Torzított szöveg létrehozása
|
||||
Settings: Beállítások:
|
||||
Shape Dynamics Alakdinamika
|
||||
Sharpen Tool Élesítő
|
||||
@@ -4255,3 +4256,8 @@ cameraRaw Yellow Sárga
|
||||
Resize Windows to Fit Ablakok átméretezése a képhez
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatikus bekezdésirány
|
||||
Left-to-right paragraph direction Balról jobbra irányuló bekezdés
|
||||
Right-to-left paragraph direction Jobbról balra irányuló bekezdés
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1478,6 +1478,7 @@
|
||||
Set shape fill type Atur jenis isian bentuk
|
||||
Set shape stroke type Atur jenis stroke bentuk
|
||||
Set the text color Atur warna teks
|
||||
Create warped text Buat teks warp
|
||||
Shadows Bayangan
|
||||
Shape: Bentuk:
|
||||
Sharpening Penajaman
|
||||
@@ -3352,3 +3353,8 @@ curves Smooth Haluskan
|
||||
Resize Windows to Fit Ubah ukuran jendela agar pas
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Arah paragraf otomatis
|
||||
Left-to-right paragraph direction Arah paragraf kiri ke kanan
|
||||
Right-to-left paragraph direction Arah paragraf kanan ke kiri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -1484,6 +1484,7 @@
|
||||
Set shape fill type Imposta tipo riempimento forma
|
||||
Set shape stroke type Imposta tipo traccia forma
|
||||
Set the text color Imposta colore testo
|
||||
Create warped text Crea testo deformato
|
||||
Shadows Ombre
|
||||
Shape: Forma:
|
||||
Sharpening Contrasto
|
||||
@@ -1999,7 +2000,7 @@
|
||||
Same folder Stessa cartella
|
||||
Save in background Salva in background
|
||||
Scratch Disks Dischi virtuali
|
||||
Screen Schermo
|
||||
Screen Scherma
|
||||
Screen resolution Risoluzione schermo
|
||||
Scrubby slider acceleration Accelerazione slider scrubby
|
||||
Sessions only Solo sessioni
|
||||
@@ -4455,3 +4456,8 @@ shape Appearance Aspetto
|
||||
Resize Windows to Fit Ridimensiona finestre per adattare
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Direzione del paragrafo automatica
|
||||
Left-to-right paragraph direction Direzione del paragrafo da sinistra a destra
|
||||
Right-to-left paragraph direction Direzione del paragrafo da destra a sinistra
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2388 and column 12.
|
@@ -1486,6 +1486,7 @@
|
||||
Set shape fill type シェイプの塗りの種類を設定
|
||||
Set shape stroke type シェイプの線の種類を設定
|
||||
Set the text color 文字色を設定
|
||||
Create warped text ワープテキストを作成
|
||||
Shadows シャドウ
|
||||
Shape: シェイプ:
|
||||
Sharpening シャープ
|
||||
@@ -4016,3 +4017,8 @@ curves Smooth スムーズ
|
||||
Resize Windows to Fit ウィンドウサイズを合わせる
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction 段落の方向を自動判別
|
||||
Left-to-right paragraph direction 左から右への段落方向
|
||||
Right-to-left paragraph direction 右から左への段落方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1475,6 +1475,7 @@
|
||||
Set shape fill type 모양 채우기 유형 설정
|
||||
Set shape stroke type 모양 획 유형 설정
|
||||
Set the text color 텍스트 색상 설정
|
||||
Create warped text 뒤틀린 텍스트 만들기
|
||||
Shadows 어두운 영역
|
||||
Shape: 모양:
|
||||
Sharpening 선명 효과
|
||||
@@ -3546,3 +3547,8 @@ curves Smooth 매끄럽게
|
||||
Resize Windows to Fit 창 크기 맞추기
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction 자동 단락 방향
|
||||
Left-to-right paragraph direction 왼쪽에서 오른쪽 단락 방향
|
||||
Right-to-left paragraph direction 오른쪽에서 왼쪽 단락 방향
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2929 and column 12.
|
@@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 21] = [
|
||||
pub static LANGUAGES: [LangInfo; 22] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -220,6 +220,8 @@ pub static LANGUAGES: [LangInfo; 21] = [
|
||||
LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese; `vi-VN` resolves here.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Hindi; `hi`, `hi-IN` locales resolve here.
|
||||
LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -280,6 +282,16 @@ impl Lang {
|
||||
LANGUAGES.iter().map(Lang)
|
||||
}
|
||||
|
||||
/// Every registered language in the order a picker lists them: English first (the source
|
||||
/// language and the fallback), then the rest by their own name with accents folded, so
|
||||
/// Čeština sits under C and Türkçe under T rather than after Z. Names in other scripts follow
|
||||
/// the Latin ones in code point order: Greek, Cyrillic, then the CJK languages.
|
||||
pub fn for_picker() -> Vec<Lang> {
|
||||
let mut rest: Vec<Lang> = Lang::all().filter(|l| l.code() != "en").collect();
|
||||
rest.sort_by_cached_key(|l| name_sort_key(l.name()));
|
||||
std::iter::once(Lang::EN).chain(rest).collect()
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
self.0.name
|
||||
}
|
||||
@@ -299,6 +311,27 @@ impl Lang {
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
|
||||
/// byte-wise sort reads alphabetically. Other scripts are left as they are.
|
||||
fn name_sort_key(name: &str) -> String {
|
||||
name.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.map(|c| match c {
|
||||
'à'..='å' | 'ā' | 'ă' | 'ą' => 'a',
|
||||
'ç' | 'ć' | 'č' => 'c',
|
||||
'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e',
|
||||
'ì'..='ï' | 'ī' => 'i',
|
||||
'ñ' | 'ń' | 'ň' => 'n',
|
||||
'ò'..='ö' | 'ø' | 'ő' => 'o',
|
||||
'ś' | 'š' => 's',
|
||||
'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u',
|
||||
'ý' | 'ÿ' => 'y',
|
||||
'ź' | 'ż' | 'ž' => 'z',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Candidate language codes for a locale tag, most specific first: `zh_TW.UTF-8` →
|
||||
/// `zh-tw`, `zh-hant`, `zh`.
|
||||
fn candidates(tag: &str) -> Vec<String> {
|
||||
@@ -436,6 +469,21 @@ mod tests {
|
||||
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
|
||||
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
|
||||
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
|
||||
const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered");
|
||||
|
||||
#[test]
|
||||
fn hindi_tags_resolve() {
|
||||
for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(HI()), "{tag}");
|
||||
}
|
||||
assert_eq!(HI().name(), "हिन्दी");
|
||||
assert_eq!(Lang::from_pref("hi"), HI());
|
||||
assert_eq!(Lang::from_pref("HI"), HI());
|
||||
assert_eq!(tr(HI(), "Layer"), "लेयर");
|
||||
assert_eq!(tr(HI(), "File"), "फ़ाइल");
|
||||
assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर");
|
||||
assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indonesian_tags_resolve() {
|
||||
@@ -456,6 +504,22 @@ mod tests {
|
||||
assert_eq!(Lang::from_pref("VI"), VI());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_lists_english_first_then_names_alphabetically() {
|
||||
let order = Lang::for_picker();
|
||||
assert_eq!(order.first().map(|l| l.code()), Some("en"));
|
||||
assert_eq!(order.len(), Lang::all().count());
|
||||
let keys: Vec<String> = order[1..].iter().map(|l| name_sort_key(l.name())).collect();
|
||||
assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}");
|
||||
let pos = |code: &str| order.iter().position(|l| l.code() == code).unwrap_or_else(|| panic!("{code} registered"));
|
||||
assert!(pos("cs") < pos("de"), "Čeština sorts under C, not after Z");
|
||||
assert!(pos("it") < pos("hu"), "Magyar sorts under M, not under its code");
|
||||
assert!(pos("tr") < pos("el"), "Latin names come before Greek");
|
||||
assert!(pos("uk") < pos("ja"), "Cyrillic names come before CJK");
|
||||
assert_eq!(name_sort_key("Tiếng Việt"), "tieng viet");
|
||||
assert_eq!(name_sort_key("Türkçe"), "turkce");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
|
||||
@@ -1174,6 +1238,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels,
|
||||
/// hardware status, rasterize prompts, History/paths menus) render in Russian, not English.
|
||||
#[test]
|
||||
fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() {
|
||||
let ru = Lang::from_code("ru").expect("ru registered");
|
||||
for kind in [
|
||||
crate::rasterize_prompt::Kind::Type,
|
||||
crate::rasterize_prompt::Kind::Shape,
|
||||
crate::rasterize_prompt::Kind::SmartObject,
|
||||
crate::rasterize_prompt::Kind::Fill,
|
||||
] {
|
||||
assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind);
|
||||
}
|
||||
assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0");
|
||||
assert_eq!(
|
||||
fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]),
|
||||
"Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1"
|
||||
);
|
||||
assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64");
|
||||
assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер");
|
||||
assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}");
|
||||
// Filter / adjustment parameter names.
|
||||
for label in [
|
||||
"Amount",
|
||||
"Radius",
|
||||
"Threshold",
|
||||
"Angle",
|
||||
"High Pass",
|
||||
"Smart Blur",
|
||||
"Reduce Noise",
|
||||
"Lens Correction",
|
||||
"Colored Pencil",
|
||||
"Angled Strokes",
|
||||
"Stained Glass",
|
||||
"Sumi-e",
|
||||
"Film Grain",
|
||||
"Unsharp Mask",
|
||||
"Lens Flare",
|
||||
] {
|
||||
assert!(has(ru, label), "Russian filter label missing: {label}");
|
||||
}
|
||||
// Dynamic composed status templates.
|
||||
assert_eq!(
|
||||
fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]),
|
||||
"Шаг 2 (file.export) не выполнен: ok"
|
||||
);
|
||||
assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied");
|
||||
assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)");
|
||||
assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)");
|
||||
// Document and layer names stay verbatim.
|
||||
let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]);
|
||||
assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}");
|
||||
}
|
||||
|
||||
/// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus
|
||||
/// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as
|
||||
/// they are (names, units) are listed in `KEEP_AS_IS`.
|
||||
@@ -1307,6 +1425,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe
|
||||
/// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend
|
||||
/// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and
|
||||
/// must not be used for the tool. `Screen` used to read `Schermo`, "the display".
|
||||
#[test]
|
||||
fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() {
|
||||
let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered");
|
||||
for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] {
|
||||
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
|
||||
}
|
||||
// The tool family stays together, and the blend modes keep Adobe's wording.
|
||||
for (source, translation) in
|
||||
[("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")]
|
||||
{
|
||||
assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic dialog labels generated by label_of() are not seen by the tl! scanner.
|
||||
#[test]
|
||||
fn color_lookup_export_scope_labels_are_translated() {
|
||||
|
||||
@@ -1467,6 +1467,7 @@
|
||||
Set shape fill type Vultype van vorm instellen
|
||||
Set shape stroke type Lijntype van vorm instellen
|
||||
Set the text color De tekstkleur instellen
|
||||
Create warped text Verbogen tekst maken
|
||||
Shadows Schaduwen
|
||||
Shape: Vorm:
|
||||
Sharpening Verscherping
|
||||
@@ -4098,3 +4099,8 @@ curves Smooth Vloeiend maken
|
||||
Resize Windows to Fit Venstergrootte aanpassen
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatische alinearichting
|
||||
Left-to-right paragraph direction Alinearichting van links naar rechts
|
||||
Right-to-left paragraph direction Alinearichting van rechts naar links
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -1498,6 +1498,7 @@
|
||||
Set shape fill type Ustaw typ wypełnienia kształtu
|
||||
Set shape stroke type Ustaw typ obrysu kształtu
|
||||
Set the text color Ustaw kolor tekstu
|
||||
Create warped text Utwórz wypaczony tekst
|
||||
Shadows Cienie
|
||||
Shape: Kształt:
|
||||
Sharpening Wyostrzanie
|
||||
@@ -3954,3 +3955,8 @@ curves Smooth Wygładź
|
||||
Resize Windows to Fit Dopasuj rozmiar okien
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatyczny kierunek akapitu
|
||||
Left-to-right paragraph direction Kierunek akapitu od lewej do prawej
|
||||
Right-to-left paragraph direction Kierunek akapitu od prawej do lewej
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1463,6 +1463,7 @@
|
||||
Set shape fill type Definir tipo de preenchimento da forma
|
||||
Set shape stroke type Definir tipo de traçado da forma
|
||||
Set the text color Definir a cor do texto
|
||||
Create warped text Criar texto deformado
|
||||
Shadows Sombras
|
||||
Shape: Forma:
|
||||
Sharpening Nitidez
|
||||
@@ -4277,3 +4278,8 @@ layerLabel No Color Sem cor
|
||||
Resize Windows to Fit Redimensionar janelas para caber
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Direção automática do parágrafo
|
||||
Left-to-right paragraph direction Direção do parágrafo da esquerda para a direita
|
||||
Right-to-left paragraph direction Direção do parágrafo da direita para a esquerda
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+1234
-2
File diff suppressed because it is too large
Load Diff
@@ -1446,6 +1446,7 @@
|
||||
Set shape fill type Podesi vrstu popune oblika
|
||||
Set shape stroke type Podesi vrstu obruba oblika
|
||||
Set the text color Postavi boju teksta
|
||||
Create warped text Napravi iskrivljeni tekst
|
||||
Shadows Senke
|
||||
Shape: Oblik:
|
||||
Sharpening Izoštravanje
|
||||
@@ -4410,3 +4411,8 @@ shape Appearance Izgled
|
||||
Resize Windows to Fit Prilagodi veličinu prozora
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Automatski smer pasusa
|
||||
Left-to-right paragraph direction Smer pasusa sleva nadesno
|
||||
Right-to-left paragraph direction Smer pasusa zdesna nalevo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -100,6 +100,7 @@ mod tests {
|
||||
(vec!["tr-TR", "en-US"], "tr"),
|
||||
(vec!["sr-Latn-RS", "en-US"], "sr"),
|
||||
(vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"),
|
||||
(vec!["hi-IN", "en-US"], "hi"),
|
||||
(vec![], "en"),
|
||||
] {
|
||||
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
|
||||
|
||||
@@ -1444,6 +1444,7 @@
|
||||
Set shape fill type Şekil doldurma türünü ayarla
|
||||
Set shape stroke type Şekil kontur türünü ayarla
|
||||
Set the text color Metin rengini ayarla
|
||||
Create warped text Çarpık metin oluştur
|
||||
Shadows Gölgeler
|
||||
Shape: Şekil:
|
||||
Sharpening Keskinleştirme
|
||||
@@ -4068,3 +4069,8 @@ cameraData Lens Lens
|
||||
Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Otomatik paragraf yönü
|
||||
Left-to-right paragraph direction Soldan sağa paragraf yönü
|
||||
Right-to-left paragraph direction Sağdan sola paragraf yönü
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -760,6 +760,7 @@
|
||||
Set shape fill type Установити тип заливки фігури
|
||||
Set shape stroke type Установити тип обведення фігури
|
||||
Set the text color Установити колір тексту
|
||||
Create warped text Створити деформований текст
|
||||
Only Web Colors Лише вебкольори
|
||||
Overlay Color Колір накладання
|
||||
Overlay Opacity Непрозорість накладання
|
||||
@@ -4047,3 +4048,8 @@ curves Smooth Згладити
|
||||
Resize Windows to Fit Підганяти розмір вікон
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Автоматичний напрямок абзацу
|
||||
Left-to-right paragraph direction Напрямок абзацу зліва направо
|
||||
Right-to-left paragraph direction Напрямок абзацу справа наліво
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1395 and column 18.
|
@@ -870,6 +870,7 @@
|
||||
Right align text Căn chữ phải
|
||||
Toggle text orientation Đổi hướng chữ
|
||||
Set the text color Đặt màu chữ
|
||||
Create warped text Tạo chữ uốn
|
||||
Cancel any current edits (Esc) Hủy chỉnh sửa hiện tại (Esc)
|
||||
Commit any current edits ({key}) Áp dụng chỉnh sửa hiện tại ({key})
|
||||
Author: Tác giả:
|
||||
@@ -3830,3 +3831,8 @@ curves Smooth Làm mượt
|
||||
Resize Windows to Fit Đổi cỡ cửa sổ cho vừa ảnh
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction Hướng đoạn văn tự động
|
||||
Left-to-right paragraph direction Hướng đoạn văn từ trái sang phải
|
||||
Right-to-left paragraph direction Hướng đoạn văn từ phải sang trái
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1517,6 +1517,7 @@
|
||||
Set shape fill type 设置形状填充类型
|
||||
Set shape stroke type 设置形状描边类型
|
||||
Set the text color 设置文字颜色
|
||||
Create warped text 创建变形文字
|
||||
Shadows 阴影
|
||||
Shape: 形状:
|
||||
Sharpening 锐化
|
||||
@@ -1666,6 +1667,7 @@
|
||||
Brush Pose 画笔姿态
|
||||
Brush Tip Shape 画笔笔尖形状
|
||||
Brush Tool 画笔工具
|
||||
Pencil Tool 铅笔工具
|
||||
Build-up 累积
|
||||
Burn Tool 加深工具
|
||||
Centimeters 厘米
|
||||
@@ -1720,6 +1722,7 @@
|
||||
ExtraBold 特粗
|
||||
ExtraLight 特细
|
||||
Eyedropper Tool 吸管工具
|
||||
Color Sampler Tool 颜色取样器工具
|
||||
Fade 渐隐
|
||||
Fill Opacity 填充不透明度
|
||||
Film & Video 电影与视频
|
||||
@@ -1736,6 +1739,7 @@
|
||||
Highlights from: 高光下限:
|
||||
History Brush Tool 历史记录画笔工具
|
||||
Horizontal Type Tool 横排文字工具
|
||||
Vertical Type Tool 直排文字工具
|
||||
Horizontal Type Mask Tool 横排文字蒙版工具
|
||||
Vertical Type Mask Tool 直排文字蒙版工具
|
||||
Image Size: 图像大小:
|
||||
@@ -1867,9 +1871,11 @@
|
||||
Tabloid Tabloid 纸
|
||||
Text Replacement 文字替换
|
||||
Thin 纤细
|
||||
To 到
|
||||
Traditional (72.27 points/inch) 传统(72.27 点/英寸)
|
||||
Transfer 传递
|
||||
Triangle Tool 三角形工具
|
||||
Up 向上
|
||||
Unsaved changes 未保存的更改
|
||||
Uses the foreground and background colours. 使用前景色和背景色。
|
||||
Watercolour look: lighter interior, darker rims. 水彩效果:内部较浅,边缘较深。
|
||||
@@ -2991,3 +2997,8 @@ curves Smooth 平滑
|
||||
Resize Windows to Fit 调整窗口大小以适合
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction 自动段落方向
|
||||
Left-to-right paragraph direction 从左到右的段落方向
|
||||
Right-to-left paragraph direction 从右到左的段落方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2357 and column 12.
|
@@ -1497,6 +1497,7 @@
|
||||
Set shape fill type 設定形狀填滿類型
|
||||
Set shape stroke type 設定形狀筆畫類型
|
||||
Set the text color 設定文字顏色
|
||||
Create warped text 建立彎曲文字
|
||||
Shadows 陰影
|
||||
Shape: 形狀:
|
||||
Sharpening 銳利化
|
||||
@@ -2960,3 +2961,8 @@ curves Smooth 平滑
|
||||
Resize Windows to Fit 調整視窗大小以符合
|
||||
|
||||
EXR EXR
|
||||
|
||||
# Paragraph direction (TYPE-218-2)
|
||||
Automatic paragraph direction 自動段落方向
|
||||
Left-to-right paragraph direction 由左至右的段落方向
|
||||
Right-to-left paragraph direction 由右至左的段落方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -151,6 +151,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("symmetry-vertical", include_bytes!("../../../assets/icons/symmetry-vertical.svg")),
|
||||
("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")),
|
||||
("text-cursor", include_bytes!("../../../assets/icons/text-cursor.svg")),
|
||||
("text-warp", include_bytes!("../../../assets/icons/text-warp.svg")),
|
||||
("toggle-left", include_bytes!("../../../assets/icons/toggle-left.svg")),
|
||||
("toggle-right", include_bytes!("../../../assets/icons/toggle-right.svg")),
|
||||
("trash", include_bytes!("../../../assets/icons/trash.svg")),
|
||||
|
||||
@@ -1520,6 +1520,26 @@ impl PhotocraftApp {
|
||||
self.session.prefs.edit(|p| p.file_handling.recent_files = r);
|
||||
}
|
||||
|
||||
/// Records a picked type family for the font menu's Recent group, capped by
|
||||
/// Preferences ▸ Type ▸ Recent Fonts (0 keeps no list), most recent first, de-duplicated.
|
||||
pub fn note_recent_font(&mut self, family: &str) {
|
||||
let cap = self.session.prefs().type_.recent_fonts.min(50) as usize;
|
||||
let r = &mut self.ui.recent_fonts;
|
||||
r.retain(|f| f != family);
|
||||
if cap == 0 {
|
||||
r.clear();
|
||||
return;
|
||||
}
|
||||
r.insert(0, family.to_string());
|
||||
r.truncate(cap);
|
||||
}
|
||||
|
||||
/// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) applies to the existing list at
|
||||
/// once, not only after the next pick.
|
||||
pub fn sync_recent_fonts(&mut self) {
|
||||
self.ui.recent_fonts.truncate(self.session.prefs().type_.recent_fonts.min(50) as usize);
|
||||
}
|
||||
|
||||
/// File › Open Recent › Clear Recent File List.
|
||||
pub fn clear_recent(&mut self) {
|
||||
self.ui.recent_files.clear();
|
||||
@@ -2345,6 +2365,9 @@ impl eframe::App for PhotocraftApp {
|
||||
field_wheel::begin_frame(&ctx);
|
||||
// Each painting tool keeps its own brush (#218), so switch the active tool's brush in
|
||||
// before anything this frame reads it (the cursor, the options bar, a stroke).
|
||||
// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) trims the existing list at
|
||||
// once, not only after the next pick.
|
||||
self.sync_recent_fonts();
|
||||
paint_mouse::sync_tool_brush(self);
|
||||
// A unit typed into a value field (`1in` in a px field) converts at the active document's
|
||||
// resolution, as in Photoshop.
|
||||
|
||||
@@ -546,7 +546,7 @@ fn language_node(pref: &str, lang: Lang) -> Node {
|
||||
role: Some(ItemRole::Language),
|
||||
};
|
||||
let mut children = vec![Node::Item(item("auto", tr(lang, "Auto"))), Node::Separator];
|
||||
children.extend(Lang::all().map(|l| Node::Item(item(l.code(), l.name()))));
|
||||
children.extend(Lang::for_picker().into_iter().map(|l| Node::Item(item(l.code(), l.name()))));
|
||||
Node::Submenu { label: tr(lang, "Language").into(), children, role: Some(ItemRole::Language) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1797,7 +1797,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, ke
|
||||
Value::String(s) if path == "interface.language" => {
|
||||
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
|
||||
let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))];
|
||||
pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name())));
|
||||
pairs.extend(crate::i18n::Lang::for_picker().into_iter().map(|l| (l.code().to_string(), l.name())));
|
||||
let mut cur = s.clone();
|
||||
crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0);
|
||||
obj.insert(k, json!(cur));
|
||||
|
||||
@@ -231,10 +231,16 @@ fn character_and_paragraph_dock_panels_share_the_headers_without_type_options()
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, [want], "tab {tab}");
|
||||
}
|
||||
// No type layer: the empty-state message, no sections.
|
||||
// No type layer: the same dedicated sections edit Type tool defaults (#5556).
|
||||
select(&mut h, px);
|
||||
assert!(headers(&h).is_empty());
|
||||
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some());
|
||||
for (tab, want) in [(0, "Character"), (1, "Paragraph")] {
|
||||
set(&mut h, json!({"dockTabs": {"character": tab}}));
|
||||
let hs = headers(&h);
|
||||
assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}");
|
||||
let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, [want], "default tab {tab}");
|
||||
}
|
||||
assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_none());
|
||||
}
|
||||
|
||||
/// #2085: like Photoshop, Opacity and Fill stack in one right-aligned column: both fields end at
|
||||
|
||||
@@ -656,6 +656,14 @@ pub struct ToolOptions {
|
||||
pub type_size: f32,
|
||||
pub type_aa: String,
|
||||
pub type_align: String,
|
||||
/// Character/Paragraph panel defaults beyond the options bar, in `type.setStyle`'s
|
||||
/// parameter format. Kept as overrides so saved engine defaults still supply unset fields.
|
||||
/// OpenType features and variable-font axes are merged per tag/axis.
|
||||
#[serde(default)]
|
||||
pub type_style_defaults: serde_json::Map<String, serde_json::Value>,
|
||||
/// Type tool: paragraph direction of new type, "auto" (from the first strong character),
|
||||
/// "ltr" or "rtl" (TYPE-218-2).
|
||||
pub type_direction: String,
|
||||
/// Clone Stamp / Healing Brush.
|
||||
pub clone_aligned: bool,
|
||||
/// Pattern Stamp: lock the tile origin in document space across strokes.
|
||||
@@ -956,6 +964,8 @@ impl Default for ToolOptions {
|
||||
type_size: 48.0,
|
||||
type_aa: "sharp".into(),
|
||||
type_align: "left".into(),
|
||||
type_style_defaults: serde_json::Map::new(),
|
||||
type_direction: "auto".into(),
|
||||
clone_aligned: true,
|
||||
pattern_stamp_aligned: true,
|
||||
pattern_stamp_impressionist: false,
|
||||
@@ -1140,6 +1150,17 @@ pub enum MadeLayer {
|
||||
Place,
|
||||
}
|
||||
|
||||
/// A paragraph-box handle drag: the handle, and the box (text-space) and layer transform as
|
||||
/// they were at the press. Every move recomputes from this snapshot, so ⌥ pressed or released
|
||||
/// mid-drag mirrors about the centre the box started with — as if it had been held from the
|
||||
/// start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BoxResize {
|
||||
pub handle: u8,
|
||||
pub shape: [f32; 4],
|
||||
pub aff: photocraft_geom::Affine,
|
||||
}
|
||||
|
||||
/// In-progress inline type editing (Type tool). Offsets are character indices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
@@ -1161,7 +1182,7 @@ pub struct TextEdit {
|
||||
pub dragging: bool,
|
||||
/// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges).
|
||||
#[serde(skip)]
|
||||
pub resize: Option<u8>,
|
||||
pub resize: Option<BoxResize>,
|
||||
/// IME composition in progress: (start, length) in characters. The preedit text lives in the
|
||||
/// layer so it lays out like typed text; each IME update replaces it.
|
||||
#[serde(skip)]
|
||||
@@ -1262,6 +1283,10 @@ pub struct UiState {
|
||||
/// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated.
|
||||
#[serde(default)]
|
||||
pub recent_files: Vec<String>,
|
||||
/// Recently picked type families, most-recent first (the font menu's Recent group). Capped by
|
||||
/// Preferences ▸ Type ▸ Recent Fonts; de-duplicated.
|
||||
#[serde(default)]
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Inline type editing session, if any.
|
||||
#[serde(default)]
|
||||
pub text_edit: Option<TextEdit>,
|
||||
@@ -1507,6 +1532,7 @@ impl Default for UiState {
|
||||
group_tools: Vec::new(),
|
||||
spring: None,
|
||||
recent_files: Vec::new(),
|
||||
recent_fonts: Vec::new(),
|
||||
text_edit: None,
|
||||
caret_blink_time: 0.0,
|
||||
text_edit_return_tool: None,
|
||||
|
||||
@@ -191,7 +191,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
|
||||
Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity),
|
||||
Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity),
|
||||
Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous),
|
||||
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align),
|
||||
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => {
|
||||
reset!(type_font, type_style, type_size, type_aa, type_align, type_direction, type_style_defaults)
|
||||
}
|
||||
Tool::Zoom => reset!(zoom_scrubby),
|
||||
Tool::SpotHealing => reset!(spot_type),
|
||||
Tool::Healing => reset!(clone_aligned, clone_sample),
|
||||
|
||||
@@ -13,6 +13,7 @@ struct State {
|
||||
button: egui::Id,
|
||||
popup: egui::Id,
|
||||
preview: FontPreview,
|
||||
recents: Vec<String>,
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, State> {
|
||||
@@ -20,16 +21,21 @@ fn harness() -> Harness<'static, State> {
|
||||
}
|
||||
|
||||
fn harness_with(preview: FontPreview) -> Harness<'static, State> {
|
||||
harness_with_recents(preview, Vec::new())
|
||||
}
|
||||
|
||||
fn harness_with_recents(preview: FontPreview, recents: Vec<String>) -> Harness<'static, State> {
|
||||
// Enough families that the unfiltered menu hits its maximum height and scrolls.
|
||||
let mut families: Vec<String> = (0..60).map(|i| format!("Family {i:02}")).collect();
|
||||
families.push("Zeta Sans".into());
|
||||
let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview };
|
||||
let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview, recents };
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
s.button = ui.make_persistent_id(egui::IdSalt::new("type-font"));
|
||||
s.popup = s.button.with("popup");
|
||||
let fams = s.families.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
|
||||
let recents = s.recents.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents);
|
||||
},
|
||||
state,
|
||||
);
|
||||
@@ -332,14 +338,15 @@ fn arrows_on_the_field_step_through_the_families_after_a_pick() {
|
||||
fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() {
|
||||
for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] {
|
||||
let families: Vec<String> = (0..20).map(|i| format!("Family {i:02}")).collect();
|
||||
let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off };
|
||||
let state =
|
||||
State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off, recents: Vec::new() };
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
let _ = ui.button("Above");
|
||||
s.button = ui.make_persistent_id(egui::IdSalt::new("type-font"));
|
||||
s.popup = s.button.with("popup");
|
||||
let fams = s.families.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &s.recents);
|
||||
let _ = ui.button("Below");
|
||||
},
|
||||
state,
|
||||
@@ -647,12 +654,14 @@ fn localized_font_names_label_the_menu_but_pick_the_family() {
|
||||
button: egui::Id::NULL,
|
||||
popup: egui::Id::NULL,
|
||||
preview: FontPreview::Medium,
|
||||
recents: Vec::new(),
|
||||
};
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup");
|
||||
let fams = s.families.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview);
|
||||
let recents = s.recents.clone();
|
||||
font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents);
|
||||
},
|
||||
state,
|
||||
);
|
||||
@@ -687,7 +696,7 @@ fn font_menu_rows_search_both_names_and_sort_by_label() {
|
||||
let labels: HashMap<String, String> =
|
||||
[("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
|
||||
let rows = |labels: Option<&HashMap<String, String>>, q: &str| -> Vec<(String, String)> {
|
||||
super::font_menu_rows(&families, labels, q).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect()
|
||||
super::font_menu_rows(&families, labels, q, &[]).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect()
|
||||
};
|
||||
let english = rows(None, "");
|
||||
assert!(english.iter().all(|(f, l)| f == l));
|
||||
@@ -698,6 +707,28 @@ fn font_menu_rows_search_both_names_and_sort_by_label() {
|
||||
assert!(rows(Some(&labels), "nothing").is_empty());
|
||||
}
|
||||
|
||||
/// Preferences ▸ Type ▸ Recent Fonts: recently used families move to the top (most recent first,
|
||||
/// once each); a query drops the hoisting so the list stays alphabetical while searching.
|
||||
#[test]
|
||||
fn recent_fonts_are_hoisted_to_the_top() {
|
||||
let families: Vec<String> = ["Arial", "Baskerville", "Courier", "Zeta Sans"].iter().map(|s| s.to_string()).collect();
|
||||
let recents: Vec<String> = vec!["Zeta Sans".into(), "Courier".into()];
|
||||
let rows = |q: &str| -> Vec<String> { super::font_menu_rows(&families, None, q, &recents).into_iter().map(|(f, _)| f.clone()).collect() };
|
||||
assert_eq!(rows(""), ["Zeta Sans", "Courier", "Arial", "Baskerville"], "recents hoist, most recent first, once each");
|
||||
assert_eq!(rows("a"), ["Arial", "Baskerville", "Zeta Sans"], "a query drops the hoisting");
|
||||
}
|
||||
|
||||
/// The menu itself draws the hoisted recents first.
|
||||
#[test]
|
||||
fn recent_fonts_appear_first_in_the_menu() {
|
||||
let mut h = harness_with_recents(FontPreview::Medium, vec!["Zeta Sans".into(), "Family 05".into()]);
|
||||
open(&mut h);
|
||||
let zeta = h.get_by_label("Zeta Sans").rect().top();
|
||||
let five = h.get_by_label("Family 05").rect().top();
|
||||
let six = h.get_by_label("Family 06").rect().top();
|
||||
assert!(zeta < five && five < six, "recents come first, in order: {zeta} {five} {six}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_over_the_closed_font_button_steps_through_the_families() {
|
||||
let mut h = harness();
|
||||
@@ -717,6 +748,30 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() {
|
||||
assert_eq!(h.state().current, "Family 00", "stops at the first family");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_over_the_closed_button_keeps_a_stable_order_with_recents() {
|
||||
let mut h = harness_with_recents(FontPreview::Medium, vec![]);
|
||||
let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center();
|
||||
h.hover_at(button);
|
||||
h.run();
|
||||
let wheel = |h: &mut Harness<'static, State>, y: f32| {
|
||||
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE });
|
||||
h.run();
|
||||
};
|
||||
wheel(&mut h, -1.0);
|
||||
assert_eq!(h.state().current, "Family 01");
|
||||
// The caller (the options bar) records the picked family as the most recent one.
|
||||
h.state_mut().recents.insert(0, "Family 01".into());
|
||||
h.run();
|
||||
wheel(&mut h, -1.0);
|
||||
assert_eq!(h.state().current, "Family 02", "the next notch keeps going, not back to the recent family");
|
||||
h.state_mut().recents.insert(0, "Family 02".into());
|
||||
h.run();
|
||||
wheel(&mut h, -1.0);
|
||||
assert_eq!(h.state().current, "Family 03");
|
||||
assert!(popup_rect(&h).is_none(), "the list stays closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_family_index_clamps_to_the_ends() {
|
||||
assert_eq!(super::wheel_family_index(0, 3, -5.0), Some(2));
|
||||
|
||||
@@ -10,6 +10,11 @@ use crate::PhotocraftApp;
|
||||
|
||||
const TAG: u64 = 1 << 60;
|
||||
|
||||
/// How long the pointer has to rest on a served family that isn't fetched yet before hovering it
|
||||
/// fetches it (seconds). Hovering asks the host for the whole family, and sweeping the font menu
|
||||
/// would otherwise download every family the pointer passes; installed fonts preview at once.
|
||||
const HOVER_FETCH_DELAY: f64 = 0.25;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TypeFontPreview {
|
||||
document: DocId,
|
||||
@@ -25,15 +30,33 @@ pub struct TypeFontPreview {
|
||||
|
||||
pub(crate) fn follow_hover(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let hover_id = egui::Id::new("type-font-hover");
|
||||
let since_id = egui::Id::new("type-font-hover-since");
|
||||
let family = ctx.data_mut(|d| {
|
||||
let family = d.get_temp::<String>(hover_id);
|
||||
d.remove::<String>(hover_id);
|
||||
family
|
||||
});
|
||||
if let Some(family) = family {
|
||||
set(app, family);
|
||||
} else if app.type_font_preview.is_some() {
|
||||
clear(app);
|
||||
let now = ctx.input(|i| i.time);
|
||||
let since = ctx.data_mut(|d| match d.get_temp::<(String, f64)>(since_id) {
|
||||
Some((f, t)) if f == family => t,
|
||||
_ => {
|
||||
d.insert_temp(since_id, (family.clone(), now));
|
||||
now
|
||||
}
|
||||
});
|
||||
let installed = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.has_family(&family);
|
||||
let rested = now - since;
|
||||
if installed || rested >= HOVER_FETCH_DELAY {
|
||||
set(app, family);
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(HOVER_FETCH_DELAY - rested));
|
||||
}
|
||||
} else {
|
||||
ctx.data_mut(|d| d.remove::<(String, f64)>(since_id));
|
||||
if app.type_font_preview.is_some() {
|
||||
clear(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,3 +158,68 @@ fn preview_key(revision: u64, layer: LayerId, range: Option<[usize; 2]>, family:
|
||||
let h = family.bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0 ^ range_key, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
|
||||
(h & (TAG - 1)) | TAG
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use serde_json::json;
|
||||
|
||||
/// One frame at `time` (seconds) with the pointer on `family`'s row in the font menu (`None`:
|
||||
/// on no row), as the menu reports it each frame.
|
||||
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, family: Option<&str>, time: f64) {
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
|
||||
if let Some(f) = family {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("type-font-hover"), f.to_string()));
|
||||
}
|
||||
follow_hover(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn previewed(app: &PhotocraftApp) -> Option<&str> {
|
||||
app.type_font_preview.as_ref().and_then(|p| p.family.as_deref())
|
||||
}
|
||||
|
||||
fn app_with_type_layer() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_served_family_is_asked_for_only_once_the_pointer_rests_on_its_row() {
|
||||
// Families the host serves but hasn't sent (own names: the served catalog is process-wide).
|
||||
let (a, b) = ("Served Hover Rest A", "Served Hover Rest B");
|
||||
let (fonts, _) = photocraft_text::served::parse_manifest(&format!("{a} | Regular | fonts/a/A.ttf\n{b} | Regular | fonts/b/B.ttf\n"));
|
||||
photocraft_text::served::add_fonts(&fonts);
|
||||
let mut app = app_with_type_layer();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// Passing over rows asks for nothing: each new row starts the wait again.
|
||||
frame(&mut app, &ctx, Some(a), 1.0);
|
||||
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY / 2.0);
|
||||
frame(&mut app, &ctx, Some(b), 1.0 + HOVER_FETCH_DELAY);
|
||||
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 1.5);
|
||||
assert_eq!(previewed(&app), None);
|
||||
|
||||
// Resting on a row asks for the family and previews it.
|
||||
frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 3.0);
|
||||
assert_eq!(previewed(&app), Some(a));
|
||||
|
||||
// Leaving the menu clears the preview, and the next hover waits again.
|
||||
frame(&mut app, &ctx, None, 3.0);
|
||||
assert_eq!(previewed(&app), None);
|
||||
frame(&mut app, &ctx, Some(a), 3.1);
|
||||
assert_eq!(previewed(&app), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_installed_family_previews_at_once() {
|
||||
let mut app = app_with_type_layer();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, Some("JetBrains Mono"), 1.0);
|
||||
assert_eq!(previewed(&app), Some("JetBrains Mono"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,8 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
|
||||
let defined = |attrs: &Value, k: &str| attrs.get(k).is_some();
|
||||
// Preferences ▸ Type ▸ Font Preview Size sizes the family picker's samples.
|
||||
let preview = app.session.prefs().type_.font_preview;
|
||||
// The fonts the pickers hoist to the top (Preferences ▸ Type ▸ Recent Fonts).
|
||||
let recents = app.ui.recent_fonts.clone();
|
||||
// Preferences ▸ Units & Rulers ▸ Type: the model stores points; the fields may show pixels
|
||||
// (by the document's resolution) or millimetres.
|
||||
let (type_unit, unit_label) = crate::type_tool::type_unit(app);
|
||||
@@ -399,7 +401,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
|
||||
ui.add_enabled_ui(on, |ui| match kind {
|
||||
"font" => {
|
||||
let mut f = v.as_str().unwrap_or("").to_string();
|
||||
if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview) {
|
||||
if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview, &recents) {
|
||||
set = Some(json!({ panel: f }));
|
||||
}
|
||||
}
|
||||
@@ -518,12 +520,12 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
|
||||
}
|
||||
|
||||
/// Searchable family combo box.
|
||||
fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview) -> bool {
|
||||
fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool {
|
||||
if !crate::type_tool::fonts_ready(ui, 140.0) {
|
||||
return false;
|
||||
}
|
||||
let mut shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() };
|
||||
let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview);
|
||||
let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview, recents);
|
||||
if changed {
|
||||
*current = shown;
|
||||
}
|
||||
@@ -617,7 +619,8 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Font family + style.
|
||||
ui.horizontal(|ui| {
|
||||
let mut f = family.clone();
|
||||
if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview) {
|
||||
if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
|
||||
app.note_recent_font(&f);
|
||||
app.ui.type_panels.glyph_family = f;
|
||||
app.ui.type_panels.glyph_style = tl!("Regular").into();
|
||||
}
|
||||
|
||||
+498
-61
@@ -15,7 +15,7 @@ use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ViewXform;
|
||||
use crate::state::TextEdit;
|
||||
use crate::state::{BoxResize, TextEdit};
|
||||
|
||||
pub const PLACEHOLDER: &str = "Lorem Ipsum";
|
||||
|
||||
@@ -183,6 +183,14 @@ fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize {
|
||||
pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize {
|
||||
let Some((l, aff, text)) = layout(app, id) else { return 0 };
|
||||
let (tx, ty) = to_text(&aff, x, y);
|
||||
// Type on a path: the point's place along the path is the position in the (single) line.
|
||||
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
|
||||
let (tx, ty) = match shape {
|
||||
Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => {
|
||||
photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0))
|
||||
}
|
||||
_ => (tx, ty),
|
||||
};
|
||||
photocraft_text::hit_char(&l, &text, tx, ty).0
|
||||
}
|
||||
|
||||
@@ -232,18 +240,23 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64
|
||||
.map(|i| i as u8)
|
||||
}
|
||||
|
||||
/// Drag handle `i` to the document point: the dragged edges follow it, the others stay put.
|
||||
fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) {
|
||||
let id = LayerId(ed.layer);
|
||||
let Some((bx, by, w, h)) = box_shape(app, id) else { return };
|
||||
let Some((_, aff, _)) = layout(app, id) else { return };
|
||||
let (px, py) = to_text(&aff, x, y);
|
||||
let (l, r, t, b) = handle_sides(i);
|
||||
/// Drag the handle of `r` (the box and transform snapshotted at the press) to the document
|
||||
/// point: the dragged edges follow it, the others stay put. With ⌥ the opposite edges mirror
|
||||
/// the drag about the centre the box started with, so the block grows symmetrically instead of
|
||||
/// from one side — pressing or releasing ⌥ mid-drag switches that on and off as if it had been
|
||||
/// held from the start.
|
||||
fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, r: BoxResize, x: f64, y: f64, alt: bool) {
|
||||
let (px, py) = to_text(&r.aff, x, y);
|
||||
let (l, rw, t, b) = handle_sides(r.handle);
|
||||
let (bx, by, w, h) = (r.shape[0], r.shape[1], r.shape[2], r.shape[3]);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (bx, by, bx + w, by + h);
|
||||
// The centre the ⌥ mirror turns about: the box as it stood at the press (every move
|
||||
// recomputes from that snapshot, so the centre holds for the whole drag).
|
||||
let (cx, cy) = (bx + w / 2.0, by + h / 2.0);
|
||||
if l {
|
||||
x0 = px.min(x1 - MIN_BOX);
|
||||
}
|
||||
if r {
|
||||
if rw {
|
||||
x1 = px.max(x0 + MIN_BOX);
|
||||
}
|
||||
if t {
|
||||
@@ -252,8 +265,28 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) {
|
||||
if b {
|
||||
y1 = py.max(y0 + MIN_BOX);
|
||||
}
|
||||
if alt {
|
||||
// The mirror keeps the centre fixed: the dragged edge clamps at MIN_BOX/2 from it, and
|
||||
// the opposite edge follows, so the block never inverts below the minimum.
|
||||
if l {
|
||||
x0 = px.min(cx - MIN_BOX / 2.0);
|
||||
x1 = 2.0 * cx - x0;
|
||||
}
|
||||
if rw {
|
||||
x1 = px.max(cx + MIN_BOX / 2.0);
|
||||
x0 = 2.0 * cx - x1;
|
||||
}
|
||||
if t {
|
||||
y0 = py.min(cy - MIN_BOX / 2.0);
|
||||
y1 = 2.0 * cy - y0;
|
||||
}
|
||||
if b {
|
||||
y1 = py.max(cy + MIN_BOX / 2.0);
|
||||
y0 = 2.0 * cy - y1;
|
||||
}
|
||||
}
|
||||
// type.edit places the box's top-left at the given document point.
|
||||
let o = aff.apply(Point::new(f64::from(x0), f64::from(y0)));
|
||||
let o = r.aff.apply(Point::new(f64::from(x0), f64::from(y0)));
|
||||
let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session}));
|
||||
}
|
||||
|
||||
@@ -271,8 +304,11 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie
|
||||
if let Some(ed) = app.ui.text_edit.clone() {
|
||||
let id = LayerId(ed.layer);
|
||||
if let Some(i) = box_handle_at(app, id, x, y) {
|
||||
if let Some(e) = app.ui.text_edit.as_mut() {
|
||||
e.resize = Some(i);
|
||||
// Snapshot the box and transform now: every move recomputes from them, so ⌥ held
|
||||
// or released mid-drag mirrors about the centre the drag started with.
|
||||
let snap = box_shape(app, id).zip(layout(app, id)).map(|((bx, by, w, h), (_, aff, _))| BoxResize { handle: i, shape: [bx, by, w, h], aff });
|
||||
if let (Some(snap), Some(e)) = (snap, app.ui.text_edit.as_mut()) {
|
||||
e.resize = Some(snap);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -323,10 +359,10 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie
|
||||
false
|
||||
}
|
||||
|
||||
pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64) {
|
||||
pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
|
||||
let Some(ed) = app.ui.text_edit.clone() else { return };
|
||||
if let Some(i) = ed.resize {
|
||||
resize_box(app, &ed, i, x, y);
|
||||
if let Some(r) = ed.resize {
|
||||
resize_box(app, &ed, r, x, y, mods.alt);
|
||||
return;
|
||||
}
|
||||
if ed.dragging {
|
||||
@@ -352,21 +388,17 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
|
||||
// Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). The Type
|
||||
// Mask tools start empty, as Photoshop's do.
|
||||
let text = if !mask && app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" };
|
||||
let mut p = json!({
|
||||
"text": text,
|
||||
"orientation": if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" },
|
||||
"font": o.type_font,
|
||||
"fontStyle": o.type_style,
|
||||
"size": o.type_size,
|
||||
"align": o.type_align,
|
||||
"color": hex(app.session.tools.foreground),
|
||||
});
|
||||
let mut p = default_style_params(app);
|
||||
p["text"] = json!(text);
|
||||
p["orientation"] = json!(if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" });
|
||||
if w >= min && h >= min {
|
||||
p["box"] = json!([start[0].min(end[0]).round(), start[1].min(end[1]).round(), w.round(), h.round()]);
|
||||
} else {
|
||||
p["x"] = json!(start[0].round());
|
||||
p["y"] = json!(start[1].round());
|
||||
}
|
||||
// A click on a shown path starts type on that path, from the point clicked (Photoshop).
|
||||
let on_path = if p.get("box").is_none() { path_at(app, start[0], start[1]) } else { None };
|
||||
let key = session_key(app);
|
||||
p["coalesce"] = json!(key);
|
||||
if let Ok(v) = app.run("type.create", p)
|
||||
@@ -375,6 +407,14 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
|
||||
if o.type_aa != "sharp" {
|
||||
let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key}));
|
||||
}
|
||||
if let Some(mut q) = on_path {
|
||||
q["layer"] = json!(id);
|
||||
q["at"] = json!([start[0], start[1]]);
|
||||
q["coalesce"] = json!(key);
|
||||
if let Err(e) = app.run("type.attachToPath", q) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
// A Type Mask's text is a hidden working layer: the canvas shows the mask overlay
|
||||
// instead, and committing turns it into the selection.
|
||||
if mask {
|
||||
@@ -399,6 +439,53 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shown path a Type tool click at document point (`x`, `y`) puts type on, as
|
||||
/// `type.attachToPath` params naming it (`shape`, `subpath`): the active shape layer's path, else
|
||||
/// the work path, within a few screen pixels, as in Photoshop. Only the horizontal Type tool
|
||||
/// starts path type for now; vertical type on a path is not laid out yet.
|
||||
pub(crate) fn path_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<serde_json::Value> {
|
||||
if app.ui.tool != crate::state::Tool::Type || app.ui.text_edit.is_some() || !app.ui.view.shows(app.ui.view.show.target_path) {
|
||||
return None;
|
||||
}
|
||||
let tol = f64::from(6.0 / app.point_zoom().max(0.01));
|
||||
let hit = |p: &photocraft_doc::vector::Path| {
|
||||
let s = match photocraft_vector::edit::hit(p, Point::new(x, y), tol, &[])? {
|
||||
photocraft_vector::edit::Hit::Anchor([s, _]) | photocraft_vector::edit::Hit::Segment([s, _], _) => s,
|
||||
photocraft_vector::edit::Hit::Handle(..) => return None,
|
||||
};
|
||||
p.subpaths.get(s).filter(|sp| sp.knots.len() >= 2).map(|_| s)
|
||||
};
|
||||
if let Some((id, p)) = crate::vector_ui::active_shape_path(app)
|
||||
&& let Some(s) = hit(&p)
|
||||
{
|
||||
return Some(json!({"shape": id, "subpath": s}));
|
||||
}
|
||||
let work = app.session.active()?.doc.work_path.as_ref()?;
|
||||
hit(work).map(|s| json!({"subpath": s}))
|
||||
}
|
||||
|
||||
/// The Type tool's pointer over a path it can follow: Photoshop's I-beam crossed by a dotted
|
||||
/// wavy line, drawn on the canvas.
|
||||
pub(crate) fn path_cursor(painter: &egui::Painter, at: Pos2) -> egui::CursorIcon {
|
||||
let beam = |c: Color32, w: f32| {
|
||||
let s = Stroke::new(w, c);
|
||||
painter.line_segment([at + egui::vec2(0.0, -8.0), at + egui::vec2(0.0, 8.0)], s);
|
||||
for dy in [-8.0, 8.0] {
|
||||
painter.line_segment([at + egui::vec2(-3.0, dy), at + egui::vec2(3.0, dy)], s);
|
||||
}
|
||||
};
|
||||
beam(Color32::WHITE, 3.0);
|
||||
beam(Color32::BLACK, 1.0);
|
||||
// The wave: short dashes along a sine through the beam's lower half.
|
||||
let wave = |t: f32| at + egui::vec2(-9.0 + 18.0 * t, 5.0 - 3.0 * t + 2.0 * (t * std::f32::consts::TAU * 1.5).sin());
|
||||
for i in (0..12).step_by(2) {
|
||||
let (a, b) = (wave(i as f32 / 12.0), wave((i + 1) as f32 / 12.0));
|
||||
painter.line_segment([a, b], Stroke::new(3.0, Color32::WHITE));
|
||||
painter.line_segment([a, b], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
|
||||
fn session_key(app: &mut PhotocraftApp) -> String {
|
||||
format!("type-{}", app.ui.alloc_id())
|
||||
}
|
||||
@@ -1140,10 +1227,24 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
let p = aff.apply(Point::new(x as f64, y as f64));
|
||||
xf.to_screen(p.x as f32, p.y as f32)
|
||||
};
|
||||
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
|
||||
// Type on a path: the caret, selection and underline follow the curve the glyphs sit on.
|
||||
let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path);
|
||||
// Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise.
|
||||
let scr = |x: f32, y: f32| -> Pos2 {
|
||||
let (x, y) = l.to_text(x, y);
|
||||
scr_t(x, y)
|
||||
match &bend {
|
||||
Some(w) => {
|
||||
let (x, y) = w.apply(f64::from(x), f64::from(y));
|
||||
scr_t(x as f32, y as f32)
|
||||
}
|
||||
None => scr_t(x, y),
|
||||
}
|
||||
};
|
||||
// A line-space run from `x0` to `x1` at height `y`: sampled, so it bends with path type.
|
||||
let run = |x0: f32, x1: f32, y: f32| -> Vec<Pos2> {
|
||||
let n = if bend.is_some() { ((x1 - x0) / 4.0).ceil().clamp(1.0, 512.0) as usize } else { 1 };
|
||||
(0..=n).map(|i| scr(x0 + (x1 - x0) * i as f32 / n as f32, y)).collect()
|
||||
};
|
||||
// Tell the OS where the caret is: this is what enables the IME and places its candidate window.
|
||||
{
|
||||
@@ -1157,7 +1258,6 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
// Frame: paragraph text shows its box with handles; point text an underline per line.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers));
|
||||
let has_transform = crate::type_transform::draw(app, painter, xf, mods);
|
||||
let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()));
|
||||
let frame = Stroke::new(1.0, t.accent);
|
||||
match shape {
|
||||
Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => {
|
||||
@@ -1176,7 +1276,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
for ln in &l.lines {
|
||||
// Horizontal: just under the baseline; vertical: the column's centre line.
|
||||
let y = if l.vertical { ln.baseline } else { ln.baseline + ln.descent * 0.25 };
|
||||
painter.line_segment([scr(ln.x0.min(0.0), y), scr(ln.x1.max(ln.x0 + 1.0), y)], Stroke::new(1.0, t.accent.gamma_multiply(0.8)));
|
||||
painter.add(egui::Shape::line(run(ln.x0.min(0.0), ln.x1.max(ln.x0 + 1.0), y), Stroke::new(1.0, t.accent.gamma_multiply(0.8))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1189,7 +1289,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q)));
|
||||
if x0 < x1 {
|
||||
let y = ln.baseline + ln.descent * 0.5;
|
||||
painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent));
|
||||
painter.add(egui::Shape::line(run(x0, x1, y), Stroke::new(1.5, t.accent)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1206,8 +1306,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX
|
||||
x1 = x1.max(ln.x1 + (ln.ascent + ln.descent) * 0.25);
|
||||
x0 = x0.min(ln.x1);
|
||||
}
|
||||
if x0 < x1 {
|
||||
let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent);
|
||||
let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent);
|
||||
if bend.is_some() {
|
||||
// On a path each glyph turns on its own, so each gets its own highlight.
|
||||
// Glyphs past the end of the path are not drawn, so neither is their highlight.
|
||||
let shown = |p: f32, q: f32| bend.as_ref().is_some_and(|w| w.place_glyph(f64::from(p + q) / 2.0) != photocraft_text::warp::Placement::Hidden);
|
||||
for (p, q) in xs.into_iter().filter(|&(p, q)| shown(p, q)) {
|
||||
painter.add(egui::Shape::convex_polygon(vec![scr(p, top), scr(q, top), scr(q, bot), scr(p, bot)], fill, Stroke::NONE));
|
||||
}
|
||||
} else if x0 < x1 {
|
||||
painter.add(egui::Shape::convex_polygon(vec![scr(x0, top), scr(x1, top), scr(x1, bot), scr(x0, bot)], fill, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
@@ -1361,8 +1468,10 @@ fn localized_families() -> Arc<HashMap<String, String>> {
|
||||
|
||||
/// The font menu's rows: each family with the label it shows (its localized name when `labels`
|
||||
/// has one, else the family), matching `query` (lowercase) by either name. With localized labels
|
||||
/// the rows sort by label, so the menu stays alphabetical as shown.
|
||||
fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String, String>>, query: &str) -> Vec<(&'a String, &'a str)> {
|
||||
/// the rows sort by label, so the menu stays alphabetical as shown. Recently used families are
|
||||
/// hoisted to the top, most recent first (Preferences ▸ Type ▸ Recent Fonts); a query hides the
|
||||
/// hoisting so the list stays strictly alphabetical while searching.
|
||||
fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String, String>>, query: &str, recents: &[String]) -> Vec<(&'a String, &'a str)> {
|
||||
let label = |f: &'a String| labels.and_then(|l| l.get(f)).map_or(f.as_str(), String::as_str);
|
||||
let mut rows: Vec<(&String, &str)> = families
|
||||
.iter()
|
||||
@@ -1372,6 +1481,16 @@ fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap<String,
|
||||
if labels.is_some_and(|l| !l.is_empty()) {
|
||||
rows.sort_by_cached_key(|(_, l)| l.to_lowercase());
|
||||
}
|
||||
if query.is_empty() {
|
||||
let mut at = 0;
|
||||
for r in recents {
|
||||
if let Some(i) = rows.iter().position(|(f, _)| *f == r) {
|
||||
let row = rows.remove(i);
|
||||
rows.insert(at, row);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -1546,8 +1665,8 @@ pub(crate) fn fonts_ready(ui: &mut egui::Ui, width: f32) -> bool {
|
||||
}
|
||||
|
||||
/// Searchable font-family combo box.
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview) -> bool {
|
||||
family_picker_in(ui, "type-font", current, width, &families(), preview)
|
||||
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool {
|
||||
family_picker_in(ui, "type-font", current, width, &families(), preview, recents)
|
||||
}
|
||||
|
||||
/// Maximum height of the font menu.
|
||||
@@ -1555,8 +1674,15 @@ const FONT_MENU_HEIGHT: f32 = 460.0;
|
||||
|
||||
/// [`font_picker`] over a given family list (tests pass their own).
|
||||
#[cfg(test)]
|
||||
fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview) -> bool {
|
||||
family_picker_in(ui, "type-font", current, width, families, preview)
|
||||
fn font_picker_in(
|
||||
ui: &mut egui::Ui,
|
||||
current: &mut String,
|
||||
width: f32,
|
||||
families: &[String],
|
||||
preview: photocraft_engine::prefs::FontPreview,
|
||||
recents: &[String],
|
||||
) -> bool {
|
||||
family_picker_in(ui, "type-font", current, width, families, preview, recents)
|
||||
}
|
||||
|
||||
/// Shared by the Type options, Character, style and Glyphs panels. Selection changes still
|
||||
@@ -1568,6 +1694,7 @@ pub(crate) fn family_picker_in(
|
||||
width: f32,
|
||||
families: &[String],
|
||||
preview: photocraft_engine::prefs::FontPreview,
|
||||
recents: &[String],
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
// A font picked from the open menu (click or Enter): the field keeps the focus afterwards.
|
||||
@@ -1612,7 +1739,7 @@ pub(crate) fn family_picker_in(
|
||||
d.insert_temp(search_id, q.clone());
|
||||
});
|
||||
let ql = q.to_lowercase();
|
||||
let rows = font_menu_rows(families, labels, &ql);
|
||||
let rows = font_menu_rows(families, labels, &ql, recents);
|
||||
if down || up {
|
||||
let index = rows.iter().position(|(f, _)| *f == current);
|
||||
let next = match index {
|
||||
@@ -1711,9 +1838,10 @@ pub(crate) fn family_picker_in(
|
||||
// the frame. Cancel it, so the arrow only steps the font.
|
||||
ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
// The wheel's step: a negative notch is the next family.
|
||||
// The wheel's step: a negative notch is the next family. Steps use a stable order (no
|
||||
// recents hoisting), so recording the picked family cannot make the next step jump back.
|
||||
let notches = f32::from(u8::from(up)) - f32::from(u8::from(down));
|
||||
let rows = font_menu_rows(families, labels, "");
|
||||
let rows = font_menu_rows(families, labels, "", &[]);
|
||||
if let Some(i) = rows.iter().position(|(f, _)| *f == current)
|
||||
&& let Some(n) = wheel_family_index(i, rows.len(), notches)
|
||||
&& let Some((f, _)) = rows.get(n)
|
||||
@@ -1733,7 +1861,10 @@ pub(crate) fn family_picker_in(
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
}
|
||||
let notches = crate::widgets::wheel_notches_of(ui, response.response.id.with("family-wheel"), hovered, 1.0);
|
||||
let rows = font_menu_rows(families, labels, "");
|
||||
// The wheel steps in a stable order: hoisting the family it just picked as recent
|
||||
// would change the order between notches and can jump back to the previous family.
|
||||
// The open menu still shows the recent families first.
|
||||
let rows = font_menu_rows(families, labels, "", &[]);
|
||||
if let Some(i) = rows.iter().position(|(f, _)| *f == current)
|
||||
&& let Some(n) = wheel_family_index(i, rows.len(), notches)
|
||||
&& let Some((f, _)) = rows.get(n)
|
||||
@@ -1835,9 +1966,139 @@ fn drag_key(ctx: &egui::Context) -> Option<String> {
|
||||
Some(format!("type-drag-{}-{}", id.value(), t.to_bits()))
|
||||
}
|
||||
|
||||
/// Apply only the supplied properties to the text-edit scope or selected type layers.
|
||||
/// The styles a new Type-tool layer will receive. Use the same command parameters for the
|
||||
/// panels and creation so defaults also survive action recording/replay. The older options-bar
|
||||
/// fields remain authoritative, and text colour continues to follow the foreground colour.
|
||||
fn default_style_params(app: &PhotocraftApp) -> serde_json::Value {
|
||||
let o = &app.ui.tool_options;
|
||||
let mut p = json!({});
|
||||
// Settings are editable JSON: only recognized formatting keys may reach type.create.
|
||||
// In particular a stray `text`, `box`, or `coalesce` key must not become a create parameter.
|
||||
let (mut c, mut para) = (photocraft_doc::text::CharStyle::default(), photocraft_doc::text::ParagraphStyle::default());
|
||||
for (key, value) in &o.type_style_defaults {
|
||||
// These belong to the existing options-bar fields. Apart from being redundant,
|
||||
// parsing a saved font key here would wait for the background font scan.
|
||||
if matches!(key.as_str(), "font" | "family" | "fontStyle" | "weight" | "italic" | "postscriptName") {
|
||||
continue;
|
||||
}
|
||||
let one = json!({ (key): value });
|
||||
if !finite_style_value(value)
|
||||
|| photocraft_engine::type_cmds::check_size_tracking(&one).is_err()
|
||||
|| photocraft_engine::type_cmds::check_kerning(&one).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if photocraft_engine::type_cmds::apply_char_props(&mut c, &one) | photocraft_engine::type_cmds::apply_para_props(&mut para, &one) {
|
||||
p[key] = value.clone();
|
||||
}
|
||||
}
|
||||
p["font"] = json!(o.type_font);
|
||||
p["fontStyle"] = json!(o.type_style);
|
||||
p["size"] = json!(o.type_size);
|
||||
p["align"] = json!(o.type_align);
|
||||
p["direction"] = json!(o.type_direction);
|
||||
p["color"] = json!(hex(app.session.tools.foreground));
|
||||
p
|
||||
}
|
||||
|
||||
/// Style numbers become f32 in the engine. JSON itself permits finite f64 values too large
|
||||
/// for that conversion; a hand-edited setting must not inject infinity into a new layer.
|
||||
/// Style collections (features, axes, colour) contain only scalar values, so no recursion.
|
||||
fn finite_style_value(value: &serde_json::Value) -> bool {
|
||||
let scalar = |v: &serde_json::Value| match v {
|
||||
serde_json::Value::Number(n) => n.as_f64().is_some_and(|n| (n as f32).is_finite()),
|
||||
serde_json::Value::Array(_) | serde_json::Value::Object(_) => false,
|
||||
_ => true,
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::Array(v) => v.iter().all(scalar),
|
||||
serde_json::Value::Object(v) => v.values().all(scalar),
|
||||
v => scalar(v),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_styles(app: &PhotocraftApp) -> (photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle) {
|
||||
let (mut c, mut para) = app.session.type_defaults.clone().unwrap_or_default();
|
||||
let mut p = default_style_params(app);
|
||||
// The saved panels can appear on the very first frame. Style parsing resolves the named
|
||||
// font through shared(), which waits for the font scan; display the saved names meanwhile,
|
||||
// and let fonts_ready/axis_sliders expose the font controls when try_shared is ready.
|
||||
if photocraft_text::try_shared().is_none() {
|
||||
c.font_family = app.ui.tool_options.type_font.clone();
|
||||
c.font_style = app.ui.tool_options.type_style.clone();
|
||||
c.postscript_name = None;
|
||||
if let Some(p) = p.as_object_mut() {
|
||||
p.remove("font");
|
||||
p.remove("fontStyle");
|
||||
}
|
||||
}
|
||||
photocraft_engine::type_cmds::apply_char_props(&mut c, &p);
|
||||
photocraft_engine::type_cmds::apply_para_props(&mut para, &p);
|
||||
(c, para)
|
||||
}
|
||||
|
||||
/// Change tool defaults without creating a document edit. The engine owns the style schema;
|
||||
/// keep its property names rather than maintaining another style parser in the shell.
|
||||
fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) {
|
||||
let Some(props) = props.as_object() else { return };
|
||||
let o = &mut app.ui.tool_options;
|
||||
for (key, value) in props {
|
||||
match key.as_str() {
|
||||
"font" => {
|
||||
if let Some(v) = value.as_str() {
|
||||
o.type_font = v.into();
|
||||
}
|
||||
}
|
||||
"fontStyle" => {
|
||||
if let Some(v) = value.as_str() {
|
||||
o.type_style = v.into();
|
||||
// A newly chosen named style replaces a weight-axis setting, as it does
|
||||
// in apply_char_props. Other axes retain their independently chosen values.
|
||||
if let Some(axes) = o.type_style_defaults.get_mut("variations").and_then(serde_json::Value::as_object_mut) {
|
||||
axes.remove("wght");
|
||||
}
|
||||
}
|
||||
}
|
||||
"size" => {
|
||||
if let Some(v) = value.as_f64() {
|
||||
o.type_size = v as f32;
|
||||
}
|
||||
}
|
||||
"align" => {
|
||||
if let Some(v) = value.as_str() {
|
||||
o.type_align = v.into();
|
||||
}
|
||||
}
|
||||
"direction" => {
|
||||
if let Some(v) = value.as_str() {
|
||||
o.type_direction = v.into();
|
||||
}
|
||||
}
|
||||
"metricsAsShown" => {} // Defaults have no layer transform.
|
||||
"features" | "variations" => {
|
||||
if let Some(patch) = value.as_object() {
|
||||
let old = o.type_style_defaults.entry(key.clone()).or_insert_with(|| json!({}));
|
||||
if let Some(old) = old.as_object_mut() {
|
||||
old.extend(patch.clone());
|
||||
} else {
|
||||
*old = value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
o.type_style_defaults.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply only the supplied properties to the text-edit scope or selected type layers; without
|
||||
/// a type target, prepare the defaults for the next layer instead.
|
||||
fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) {
|
||||
let Some(mut p) = formatting_params(app) else { return };
|
||||
let Some(mut p) = formatting_params(app) else {
|
||||
apply_defaults(app, &props);
|
||||
return;
|
||||
};
|
||||
if let Some(props) = props.as_object() {
|
||||
for (key, value) in props {
|
||||
p[key] = value.clone();
|
||||
@@ -1911,7 +2172,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Font family and style menus (one placeholder in their place while the fonts load).
|
||||
if fonts_ready(ui, 170.0 + ui.spacing().item_spacing.x + 110.0) {
|
||||
if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview) {
|
||||
if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
|
||||
app.note_recent_font(&fam);
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
let st = styles(&fam);
|
||||
style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) };
|
||||
@@ -1966,6 +2228,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
crate::widgets::vline(ui, 22.0);
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, d) in direction_items() {
|
||||
if direction_button(ui, d, o.type_direction == key, tip).clicked() {
|
||||
app.ui.tool_options.type_direction = key.into();
|
||||
apply(app, ui.ctx(), json!({"direction": key}));
|
||||
}
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
crate::widgets::vline(ui, 22.0);
|
||||
// Text colour swatch with a picker popup.
|
||||
let c = shown
|
||||
.as_ref()
|
||||
@@ -1984,6 +2255,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
if resp.clicked() {
|
||||
open_color_picker(app, [c[0], c[1], c[2]]);
|
||||
}
|
||||
// Create warped text, after the swatch as in Photoshop (#5911): the Type › Warp Text… dialog.
|
||||
let can_warp = crate::menus::is_enabled(app, "type.warpText");
|
||||
if ui.add_enabled_ui(can_warp, |ui| crate::icons::button(ui, "text-warp", 24.0, false, tl!("Create warped text"))).inner.clicked()
|
||||
&& let Err(e) = crate::menus::invoke(app, &ui.ctx().clone(), "type.warpText", json!({}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
if app.ui.text_edit.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(8.0);
|
||||
@@ -2121,7 +2400,7 @@ fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, r, icon, 12.0, t.text_dim);
|
||||
// Photoshop's Character panel icons scrub the field beside them.
|
||||
crate::widgets::scrub_label(ui, &resp.on_hover_text(tip));
|
||||
crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip)));
|
||||
}
|
||||
|
||||
/// Labelled numeric field (Photoshop's icon + value pairs). Returns the new value when edited.
|
||||
@@ -2169,6 +2448,47 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph direction choices for the Paragraph panel and the options bar (TYPE-218-2): the
|
||||
/// `direction` value `type.setStyle` takes, the tooltip, and the direction it sets.
|
||||
fn direction_items() -> [(&'static str, &'static str, photocraft_doc::text::TextDirection); 3] {
|
||||
use photocraft_doc::text::TextDirection as D;
|
||||
[
|
||||
("auto", tl!("Automatic paragraph direction"), D::Auto),
|
||||
("ltr", tl!("Left-to-right paragraph direction"), D::Ltr),
|
||||
("rtl", tl!("Right-to-left paragraph direction"), D::Rtl),
|
||||
]
|
||||
}
|
||||
|
||||
/// A pilcrow over an arrow pointing the way the paragraph runs; Auto points both ways.
|
||||
fn direction_glyph(p: &egui::Painter, r: egui::Rect, d: photocraft_doc::text::TextDirection, c: Color32) {
|
||||
use photocraft_doc::text::TextDirection as D;
|
||||
let g = p.layout_no_wrap("¶".to_string(), egui::FontId::proportional(11.0), c);
|
||||
p.galley(egui::pos2(r.center().x - g.size().x / 2.0, r.top() - 2.0), g, c);
|
||||
let (y, s) = (r.bottom() - 1.5, Stroke::new(1.3, c));
|
||||
p.line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], s);
|
||||
let head = |x: f32, towards: f32| {
|
||||
for dy in [-3.0, 3.0] {
|
||||
p.line_segment([egui::pos2(x, y), egui::pos2(x - towards * 3.0, y + dy)], s);
|
||||
}
|
||||
};
|
||||
if d != D::Rtl {
|
||||
head(r.right(), 1.0);
|
||||
}
|
||||
if d != D::Ltr {
|
||||
head(r.left(), -1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The options bar's 24 px square button for one paragraph direction, styled like its icon buttons.
|
||||
fn direction_button(ui: &mut egui::Ui, d: photocraft_doc::text::TextDirection, selected: bool, tip: &str) -> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click());
|
||||
let tint = crate::icons::button_chrome(ui, r, selected, resp.hovered());
|
||||
direction_glyph(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)), d, tint);
|
||||
let tip = tip.to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &tip));
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`.
|
||||
fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
@@ -2238,7 +2558,17 @@ fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc::
|
||||
/// characters (or its whole text) while typing, else every selected type layer. The new state
|
||||
/// is explicit, so it follows the button the user saw rather than the layer's first run.
|
||||
pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) {
|
||||
let Some(mut p) = formatting_params(app) else { return };
|
||||
let Some(mut p) = formatting_params(app) else {
|
||||
if let Some((tag, _, _)) = OPENTYPE_BUTTONS.iter().find(|(_, command, _)| *command == id) {
|
||||
let props = match *tag {
|
||||
"liga" => json!({"ligatures": !shown_on}),
|
||||
"dlig" => json!({"discretionaryLigatures": !shown_on}),
|
||||
_ => json!({"features": { (*tag): !shown_on }}),
|
||||
};
|
||||
apply_defaults(app, &props);
|
||||
}
|
||||
return;
|
||||
};
|
||||
p["on"] = json!(!shown_on);
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
@@ -2305,28 +2635,22 @@ fn cell_width(avail: f32, n: usize) -> f32 {
|
||||
|
||||
/// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155).
|
||||
pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
type_sections(app, ui, true, true);
|
||||
type_sections(app, ui, true, true, false);
|
||||
type_options(app, ui);
|
||||
}
|
||||
|
||||
/// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock
|
||||
/// group. Without a type layer (or a type edit) there is nothing to show.
|
||||
/// group. Without a type target they edit the defaults for the next Type-tool layer.
|
||||
pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) {
|
||||
if styles_at(app).is_none() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Select a type layer to edit its character and paragraph settings.")).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
type_sections(app, ui, !paragraph, paragraph);
|
||||
type_sections(app, ui, !paragraph, paragraph, true);
|
||||
}
|
||||
|
||||
/// The Character and/or Paragraph sections, with the shared collapsible headers and fields that
|
||||
/// fill the panel width (#155).
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) {
|
||||
fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool, show_defaults: bool) {
|
||||
use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section};
|
||||
use crate::theme::ROW_GAP;
|
||||
let Some((c, para)) = styles_at(app) else { return };
|
||||
let Some((c, para)) = styles_at(app).or_else(|| show_defaults.then(|| default_styles(app))) else { return };
|
||||
// Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points.
|
||||
let (u, unit) = type_unit(app);
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
@@ -2345,7 +2669,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
let mut ready = true;
|
||||
row(ui, &mut |ui| {
|
||||
ready = fonts_ready(ui, full);
|
||||
if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview) {
|
||||
if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) {
|
||||
app.note_recent_font(&fam);
|
||||
app.ui.tool_options.type_font = fam.clone();
|
||||
apply(app, ui.ctx(), json!({"font": fam}));
|
||||
}
|
||||
@@ -2522,6 +2847,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (key, tip, d) in direction_items() {
|
||||
let clicked = toggle_cell(ui, cw, para.direction == d, tip, |ui, r, col| {
|
||||
let g = egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0));
|
||||
direction_glyph(ui.painter(), g, d, col);
|
||||
});
|
||||
if clicked {
|
||||
apply(app, ui.ctx(), json!({"direction": key}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
row(ui, &mut |ui| {
|
||||
if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) {
|
||||
apply(app, ui.ctx(), json!({"startIndent": v / u}));
|
||||
@@ -2655,6 +2993,41 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
/// Preferences ▸ Type ▸ Recent Fonts caps the recorded list; a re-pick moves to the front and
|
||||
/// 0 keeps no list.
|
||||
#[test]
|
||||
fn note_recent_font_caps_and_de_duplicates() {
|
||||
let mut app = app();
|
||||
for f in ["A", "B", "C"] {
|
||||
app.note_recent_font(f);
|
||||
}
|
||||
assert_eq!(app.ui.recent_fonts, ["C", "B", "A"]);
|
||||
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap();
|
||||
app.note_recent_font("D");
|
||||
assert_eq!(app.ui.recent_fonts, ["D", "C"], "the cap trims the tail");
|
||||
app.note_recent_font("C");
|
||||
assert_eq!(app.ui.recent_fonts, ["C", "D"], "a re-pick moves to the front");
|
||||
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap();
|
||||
app.note_recent_font("E");
|
||||
assert!(app.ui.recent_fonts.is_empty(), "0 keeps no list");
|
||||
}
|
||||
|
||||
/// A smaller Recent Fonts cap (or 0) applies to the existing list at once, without waiting
|
||||
/// for the next pick (CodeRabbit review of #3559).
|
||||
#[test]
|
||||
fn lowering_the_recent_fonts_cap_applies_at_once() {
|
||||
let mut app = app();
|
||||
for f in ["A", "B", "C"] {
|
||||
app.note_recent_font(f);
|
||||
}
|
||||
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap();
|
||||
app.sync_recent_fonts();
|
||||
assert_eq!(app.ui.recent_fonts, ["C", "B"], "the smaller cap trims the tail without a pick");
|
||||
app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap();
|
||||
app.sync_recent_fonts();
|
||||
assert!(app.ui.recent_fonts.is_empty(), "0 clears the list");
|
||||
}
|
||||
|
||||
fn layer_text(app: &PhotocraftApp) -> String {
|
||||
let ed = app.ui.text_edit.as_ref().unwrap();
|
||||
current_text(app, LayerId(ed.layer)).unwrap()
|
||||
@@ -2775,6 +3148,62 @@ mod tests {
|
||||
assert!(app.ui.text_edit.is_none());
|
||||
}
|
||||
|
||||
/// TYPE-218-1: a Type tool click on the work path starts type on the path from the point
|
||||
/// clicked, in one history step with the placeholder selected; a click off it is point text.
|
||||
#[test]
|
||||
fn click_on_a_path_starts_type_on_the_path() {
|
||||
use photocraft_doc::text::TextShape;
|
||||
let mut app = app();
|
||||
app.session
|
||||
.edit("path", |doc, _| {
|
||||
doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(20.0, 100.0), (380.0, 100.0)])]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(path_at(&app, 200.0, 101.0).is_some());
|
||||
assert!(path_at(&app, 200.0, 160.0).is_none(), "off the path");
|
||||
let before = app.session.active().unwrap().history.entries().len();
|
||||
assert!(!pointer_down(&mut app, 200.0, 101.0, egui::Modifiers::NONE));
|
||||
pointer_up(&mut app, [200.0, 101.0], [200.0, 101.0]);
|
||||
let ed = app.ui.text_edit.clone().unwrap();
|
||||
assert_eq!((ed.anchor, ed.caret), (0, PLACEHOLDER.chars().count()), "the placeholder is selected");
|
||||
let shape = text_layer(&app.session.active().unwrap().doc, LayerId(ed.layer)).unwrap().shape.clone();
|
||||
assert!(matches!(shape, TextShape::Path { start, flip: false, .. } if (start - 180.0).abs() < 0.5), "{shape:?}");
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one step for the click");
|
||||
insert(&mut app, "On the line");
|
||||
assert_eq!(layer_text(&app), "On the line");
|
||||
commit(&mut app);
|
||||
// With the vertical tool a click does not start path type.
|
||||
app.ui.tool = crate::state::Tool::VerticalType;
|
||||
assert!(path_at(&app, 200.0, 101.0).is_none());
|
||||
app.ui.tool = crate::state::Tool::Type;
|
||||
pointer_up(&mut app, [200.0, 160.0], [200.0, 160.0]);
|
||||
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
|
||||
assert_eq!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, TextShape::Point);
|
||||
}
|
||||
|
||||
/// A click in type on a path puts the caret at the glyph there along the curve, not where the
|
||||
/// straight line would have it.
|
||||
#[test]
|
||||
fn caret_hits_type_on_a_path_along_the_curve() {
|
||||
let mut app = app();
|
||||
app.session
|
||||
.edit("path", |doc, _| {
|
||||
doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(300.0, 10.0), (300.0, 195.0)])]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
pointer_up(&mut app, [300.0, 20.0], [300.0, 20.0]);
|
||||
insert(&mut app, "ABCDEFGH");
|
||||
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
|
||||
assert!(matches!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, photocraft_doc::text::TextShape::Path { .. }));
|
||||
// The type runs down the path: near its start the caret is at the beginning, far down at the end.
|
||||
assert_eq!(offset_at(&mut app, id, 300.0, 18.0), 0);
|
||||
// (The straight line would put both of these at the start: they are left of its first glyph.)
|
||||
let (mid, far) = (offset_at(&mut app, id, 296.0, 60.0), offset_at(&mut app, id, 296.0, 190.0));
|
||||
assert!(0 < mid && mid < far, "{mid} {far}");
|
||||
}
|
||||
|
||||
/// Preferences ▸ Units & Rulers ▸ Type switches the display unit of type metrics: points by
|
||||
/// default, pixels by the document's resolution, or millimetres.
|
||||
#[test]
|
||||
@@ -2850,9 +3279,9 @@ mod tests {
|
||||
let steps = app.session.active().unwrap().history.entries().len();
|
||||
// Top-left corner: the box keeps its bottom-right corner.
|
||||
assert!(pointer_down(&mut app, 10.0, 10.0, egui::Modifiers::NONE));
|
||||
assert_eq!(app.ui.text_edit.as_ref().unwrap().resize, Some(0));
|
||||
pointer_move(&mut app, 20.0, 25.0);
|
||||
pointer_move(&mut app, 30.0, 30.0);
|
||||
assert_eq!(app.ui.text_edit.as_ref().unwrap().resize.map(|r| r.handle), Some(0));
|
||||
pointer_move(&mut app, 20.0, 25.0, egui::Modifiers::NONE);
|
||||
pointer_move(&mut app, 30.0, 30.0, egui::Modifiers::NONE);
|
||||
pointer_up(&mut app, [10.0, 10.0], [30.0, 30.0]);
|
||||
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, 80.0, 30.0)));
|
||||
let aff = layout(&mut app, id).unwrap().1;
|
||||
@@ -2862,9 +3291,17 @@ mod tests {
|
||||
assert!(app.ui.text_edit.is_some());
|
||||
// Right edge: only the width changes, and never below the minimum.
|
||||
assert!(pointer_down(&mut app, 110.0, 45.0, egui::Modifiers::NONE));
|
||||
pointer_move(&mut app, 0.0, 99.0);
|
||||
pointer_move(&mut app, 0.0, 99.0, egui::Modifiers::NONE);
|
||||
pointer_up(&mut app, [110.0, 45.0], [0.0, 99.0]);
|
||||
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX, 30.0)));
|
||||
// ⌥ mirrors the drag about the centre: the left edge follows the right one out.
|
||||
assert!(pointer_down(&mut app, 34.0, 45.0, egui::Modifiers::ALT));
|
||||
pointer_move(&mut app, 70.0, 45.0, egui::Modifiers::ALT);
|
||||
pointer_up(&mut app, [34.0, 45.0], [70.0, 45.0]);
|
||||
assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX + 72.0, 30.0)));
|
||||
let aff = layout(&mut app, id).unwrap().1;
|
||||
// Text space −36..40 (centre 2) through the translation: the doc centre stays at 32.
|
||||
assert_eq!((aff.m[4], aff.m[5]), (-6.0, 30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -74,6 +74,240 @@ fn numeric_type_fields_have_localized_accessible_names() {
|
||||
assert_eq!(*h.state(), 36.0);
|
||||
}
|
||||
|
||||
/// #5556: the dedicated panels remain usable before there is any type layer, including
|
||||
/// before opening a document. Properties still belongs to the selected layer.
|
||||
#[test]
|
||||
fn type_panels_show_default_controls_without_a_type_target() {
|
||||
for app in [PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()), new_app()] {
|
||||
let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
ui.columns(2, |columns| {
|
||||
super::character_panel(app, &mut columns[0], false);
|
||||
super::character_panel(app, &mut columns[1], true);
|
||||
});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, Default::default());
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Faux Bold");
|
||||
h.get_by_label("Fractions");
|
||||
h.get_by_label("Center text");
|
||||
h.get_by_label("Hyphenate");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Font size");
|
||||
}
|
||||
}
|
||||
|
||||
mod panel_defaults {
|
||||
use super::*;
|
||||
use crate::state::{Tool, ToolOptions};
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use photocraft_doc::text::{Caps, Kerning, Orientation, TextAlign, TextShape};
|
||||
|
||||
fn panels(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
ui.columns(2, |columns| {
|
||||
super::super::character_panel(app, &mut columns[0], false);
|
||||
super::super::character_panel(app, &mut columns[1], true);
|
||||
});
|
||||
}
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, Default::default());
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) {
|
||||
h.get_by_role_and_label(Role::SpinButton, label).click();
|
||||
h.run();
|
||||
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
|
||||
h.event(egui::Event::Text(value.into()));
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_defaults_are_editable_and_reach_every_type_tool() {
|
||||
let mut h = panels(new_app());
|
||||
let revision = h.state().session.active().unwrap().revision;
|
||||
let layers = h.state().session.active().unwrap().doc.walk().len();
|
||||
number(&mut h, "Font size", "36");
|
||||
number(&mut h, "Tracking (1/1000 em)", "80");
|
||||
number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "44");
|
||||
number(&mut h, "Indent left margin", "9");
|
||||
for label in ["Faux Bold", "Fractions", "Standard Ligatures", "Discretionary Ligatures", "Center text", "Hyphenate"] {
|
||||
h.get_by_label(label).click();
|
||||
h.run();
|
||||
}
|
||||
assert_eq!(h.get_by_label("Fractions").accesskit_node().toggled(), Some(Toggled::True));
|
||||
assert_eq!(h.get_by_label("Standard Ligatures").accesskit_node().toggled(), Some(Toggled::False));
|
||||
assert_eq!(h.state().session.active().unwrap().revision, revision, "pre-styling does not create document history");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.walk().len(), layers);
|
||||
let options = h.state().ui.tool_options.clone();
|
||||
for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] {
|
||||
for paragraph in [false, true] {
|
||||
let mut app = new_app();
|
||||
app.ui.tool_options = options.clone();
|
||||
app.ui.tool = tool;
|
||||
app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0];
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
super::super::pointer_up(&mut app, [100.0, 100.0], if paragraph { [500.0, 400.0] } else { [100.0, 100.0] });
|
||||
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
|
||||
let t = text(&app, id);
|
||||
let c = &t.char_runs()[0].style;
|
||||
let p = &t.paragraph_runs()[0].style;
|
||||
assert_eq!(c.size_pt, 36.0);
|
||||
assert_eq!(c.tracking, 80.0);
|
||||
assert_eq!(c.leading_pt, Some(44.0));
|
||||
assert!(c.faux_bold && c.discretionary_ligatures && !c.ligatures);
|
||||
assert!(photocraft_engine::type_extra_cmds::feature_on(c, "frac"));
|
||||
assert_eq!(c.color.to_rgba8(), [51, 102, 153, 255]);
|
||||
assert_eq!(p.align, TextAlign::Center);
|
||||
assert_eq!(p.start_indent_pt, 9.0);
|
||||
assert!(p.hyphenate);
|
||||
assert_eq!(t.orientation, if tool.is_vertical_type() { Orientation::Vertical } else { Orientation::Horizontal });
|
||||
assert_eq!(matches!(t.shape, TextShape::Box { .. }), paragraph);
|
||||
assert_eq!(app.ui.text_edit.as_ref().unwrap().mask, tool.is_type_mask());
|
||||
assert!(app.session.active().unwrap().revision > revision);
|
||||
super::super::cancel(&mut app);
|
||||
assert!(app.session.active().unwrap().doc.layer(id).is_none(), "creating and formatting is one undoable edit");
|
||||
assert_eq!(app.ui.tool_options, options, "cancelling text does not discard the chosen defaults");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_defaults_merge_serialize_and_reset_without_replacing_legacy_options() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
super::super::apply(
|
||||
&mut app,
|
||||
&ctx,
|
||||
json!({
|
||||
"fontStyle": "Bold", "size": 32, "kerning": 25, "caps": "smallCaps",
|
||||
"baselineShift": 3, "horizontalScale": 90, "verticalScale": 110,
|
||||
"underline": true, "strikethrough": true, "fauxItalic": true,
|
||||
"firstLineIndent": 5, "endIndent": 7, "spaceBefore": 11, "spaceAfter": 13,
|
||||
"features": {"swsh": 1}, "variations": {"wght": 650, "wdth": 90}
|
||||
}),
|
||||
);
|
||||
super::super::apply(&mut app, &ctx, json!({"features": {"frac": 1}, "variations": {"slnt": -8}}));
|
||||
let (c, p) = super::super::default_styles(&app);
|
||||
assert_eq!((c.size_pt, c.kern, c.kerning, c.caps), (32.0, 25.0, Kerning::Off, Caps::SmallCaps));
|
||||
assert_eq!((c.baseline_shift_pt, c.horizontal_scale, c.vertical_scale), (3.0, 0.9, 1.1));
|
||||
assert!(c.underline && c.strikethrough && c.faux_italic);
|
||||
assert_eq!((p.first_line_indent_pt, p.end_indent_pt, p.space_before_pt, p.space_after_pt), (5.0, 7.0, 11.0, 13.0));
|
||||
assert_eq!(c.features.iter().map(|f| f.tag.as_str()).collect::<Vec<_>>(), ["frac", "swsh"]);
|
||||
assert_eq!(c.variations.iter().map(|v| (v.axis.as_str(), v.value)).collect::<Vec<_>>(), [("slnt", -8.0), ("wdth", 90.0), ("wght", 650.0)]);
|
||||
// A named style replaces wght; it must not come back when a later layer is created.
|
||||
super::super::apply(&mut app, &ctx, json!({"fontStyle": "Regular"}));
|
||||
let (c, _) = super::super::default_styles(&app);
|
||||
assert!(!c.variations.iter().any(|v| v.axis == "wght"));
|
||||
assert_eq!(c.variations.len(), 2);
|
||||
let saved = serde_json::to_value(&app.ui.tool_options).unwrap();
|
||||
let restored: ToolOptions = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(restored, app.ui.tool_options);
|
||||
crate::tool_memory::persist(&mut app, &ctx);
|
||||
let remembered = app.session.prefs().tool_options.clone();
|
||||
let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
restarted.session.prefs.edit(|p| p.tool_options = remembered);
|
||||
crate::tool_memory::restore(&mut restarted);
|
||||
assert_eq!(restarted.ui.tool_options, restored, "the real preference restore path retains panel defaults");
|
||||
let mut legacy = saved;
|
||||
legacy.as_object_mut().unwrap().remove("type_style_defaults");
|
||||
let legacy: ToolOptions = serde_json::from_value(legacy).unwrap();
|
||||
assert_eq!((legacy.type_font.as_str(), legacy.type_style.as_str(), legacy.type_size), ("Inter", "Regular", 32.0));
|
||||
assert!(legacy.type_style_defaults.is_empty());
|
||||
for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] {
|
||||
let mut reset = restored.clone();
|
||||
crate::tool_reset::reset_options(&mut reset, tool);
|
||||
assert!(reset.type_style_defaults.is_empty());
|
||||
assert_eq!(reset.type_size, ToolOptions::default().type_size);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_text_edits_leave_panel_defaults_and_other_characters_untouched() {
|
||||
let mut app = new_app();
|
||||
let ctx = egui::Context::default();
|
||||
super::super::apply(&mut app, &ctx, json!({"tracking": 80, "fauxBold": true}));
|
||||
let defaults = app.ui.tool_options.type_style_defaults.clone();
|
||||
let id = LayerId(app.run("type.create", json!({"text": "AéBC", "size": 24})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.ui.text_edit = Some(crate::state::TextEdit {
|
||||
layer: id.0,
|
||||
document: None,
|
||||
caret: 2,
|
||||
anchor: 1,
|
||||
session: "panel-default-selection".into(),
|
||||
created: false,
|
||||
mask: false,
|
||||
dragging: false,
|
||||
resize: None,
|
||||
preedit: None,
|
||||
});
|
||||
super::super::apply(&mut app, &ctx, json!({"tracking": 15}));
|
||||
super::super::toggle_opentype(&mut app, "type.openType.fractions", false);
|
||||
let runs = text(&app, id).char_runs();
|
||||
assert_eq!(runs.iter().map(|r| (r.len, r.style.tracking)).collect::<Vec<_>>(), [(1, 0.0), (2, 15.0), (2, 0.0)]);
|
||||
assert!(photocraft_engine::type_extra_cmds::feature_on(&runs[1].style, "frac"));
|
||||
assert!(!photocraft_engine::type_extra_cmds::feature_on(&runs[0].style, "frac"));
|
||||
assert_eq!(app.ui.tool_options.type_style_defaults, defaults);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(text(&app, id).char_runs().len(), 1, "selection edits keep their typing-session coalescing");
|
||||
assert_eq!(text(&app, id).char_runs()[0].style.tracking, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_text_color_uses_foreground_and_cancel_preserves_it() {
|
||||
let mut app = new_app();
|
||||
let before = app.session.tools.foreground;
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let dialog = super::super::open_color_picker(&mut app, [0.0; 3]);
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff8800"));
|
||||
crate::dialogs::cancel(&mut app, dialog).unwrap();
|
||||
assert_eq!(app.session.tools.foreground, before);
|
||||
let dialog = super::super::open_color_picker(&mut app, [0.0; 3]);
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#336699"));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert_eq!(super::super::default_styles(&app).0.color.to_rgba8(), [51, 102, 153, 255]);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
super::super::pointer_up(&mut app, [100.0, 100.0], [100.0, 100.0]);
|
||||
let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer);
|
||||
assert_eq!(text(&app, id).char_runs()[0].style.color.to_rgba8(), [51, 102, 153, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_saved_defaults_cannot_become_creation_parameters() {
|
||||
let mut app = new_app();
|
||||
app.ui.tool_options.type_style_defaults =
|
||||
json!({"tracking": 20, "text": "Injected", "box": [0, 0, 2, 2], "coalesce": "unrelated"}).as_object().unwrap().clone();
|
||||
let p = super::super::default_style_params(&app);
|
||||
assert_eq!(p["tracking"], 20);
|
||||
assert!(p.get("text").is_none() && p.get("box").is_none() && p.get("coalesce").is_none());
|
||||
app.ui.tool_options.type_style_defaults = json!({
|
||||
"tracking": 1e300, "leading": 1e300, "spaceAfter": 1e300,
|
||||
"variations": {"wght": 1e300}, "features": {"frac": {"bad": 1}}, "fauxBold": true
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let p = super::super::default_style_params(&app);
|
||||
for key in ["tracking", "leading", "spaceAfter", "variations", "features"] {
|
||||
assert!(p.get(key).is_none(), "hostile {key} is ignored");
|
||||
}
|
||||
let (c, para) = super::super::default_styles(&app);
|
||||
assert!(c.faux_bold && c.leading_pt.is_none() && c.tracking == 0.0 && para.space_after_pt == 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
|
||||
make_harness(ppp, app, false)
|
||||
}
|
||||
@@ -1617,6 +1851,7 @@ fn the_font_menu_lists_served_families() {
|
||||
mod multi_layer_formatting {
|
||||
use super::*;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use photocraft_doc::text::{AntiAlias, CharStyle, Orientation, TextAlign};
|
||||
|
||||
fn selected(app: &mut PhotocraftApp, ids: &[LayerId]) {
|
||||
@@ -1696,6 +1931,62 @@ mod multi_layer_formatting {
|
||||
});
|
||||
}
|
||||
|
||||
/// The Paragraph section's direction buttons set the selected layers' paragraph direction in
|
||||
/// one undo step, show the current one, and leave unselected layers and the tool's default for
|
||||
/// new type alone (TYPE-218-2).
|
||||
#[test]
|
||||
fn paragraph_direction_buttons_set_and_show_the_direction() {
|
||||
use egui::accesskit::Toggled;
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use photocraft_doc::text::TextDirection;
|
||||
let (app, ids) = fixture();
|
||||
let dir = |app: &PhotocraftApp, id: LayerId| text(app, id).paragraph_runs()[0].style.direction;
|
||||
let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled();
|
||||
let mut h = controls(app, true);
|
||||
assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::True), "new type follows its text");
|
||||
assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::False));
|
||||
let steps = h.state().session.active().unwrap().history.entries().len();
|
||||
h.get_by_label("Right-to-left paragraph direction").click();
|
||||
h.run();
|
||||
for id in &ids[..2] {
|
||||
assert_eq!(dir(h.state(), *id), TextDirection::Rtl, "selected layers get the direction");
|
||||
}
|
||||
assert_eq!(dir(h.state(), ids[2]), TextDirection::Auto, "an unselected layer is untouched");
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one undo step");
|
||||
assert_eq!(h.state().ui.tool_options.type_direction, "auto", "the panel edits layers, not new type");
|
||||
h.run();
|
||||
assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::True), "the button shows it");
|
||||
assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::False));
|
||||
h.get_by_label("Left-to-right paragraph direction").click();
|
||||
h.run();
|
||||
assert_eq!(dir(h.state(), ids[0]), TextDirection::Ltr);
|
||||
assert!(h.state_mut().session.undo());
|
||||
assert!(h.state_mut().session.undo());
|
||||
for id in &ids[..2] {
|
||||
assert_eq!(dir(h.state(), *id), TextDirection::Auto, "undo restores it");
|
||||
}
|
||||
}
|
||||
|
||||
/// The options bar's direction buttons set the selected layers' direction and the one new
|
||||
/// type starts with, as its alignment buttons do (TYPE-218-2).
|
||||
#[test]
|
||||
fn options_bar_direction_sets_selected_layers_and_new_type() {
|
||||
use photocraft_doc::text::TextDirection;
|
||||
let (app, ids) = fixture();
|
||||
let mut h = controls(app, false);
|
||||
h.get_by_label("Right-to-left paragraph direction").click();
|
||||
h.run();
|
||||
assert_eq!(h.state().ui.tool_options.type_direction, "rtl");
|
||||
for id in &ids[..2] {
|
||||
assert_eq!(text(h.state(), *id).paragraph_runs()[0].style.direction, TextDirection::Rtl);
|
||||
}
|
||||
assert_eq!(text(h.state(), ids[2]).paragraph_runs()[0].style.direction, TextDirection::Auto);
|
||||
super::super::pointer_up(h.state_mut(), [40.0, 60.0], [40.0, 60.0]);
|
||||
let id = h.state().session.active().unwrap().active_layer.unwrap();
|
||||
assert!(!ids.contains(&id), "a new type layer");
|
||||
assert_eq!(text(h.state(), id).paragraph_runs()[0].style.direction, TextDirection::Rtl, "new type starts right to left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_font_family_preserves_peer_faces_and_batches_layer_flags() {
|
||||
let (app, ids) = fixture();
|
||||
@@ -2038,6 +2329,28 @@ mod multi_layer_formatting {
|
||||
assert_eq!(style(h.state(), id, 0), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// #5911: the options bar's Create warped text button opens Warp Text for the selected type
|
||||
/// layers, as Type › Warp Text… does; with no type layer to warp it is greyed.
|
||||
#[test]
|
||||
fn options_bar_warp_button_warps_the_selected_type_layers() {
|
||||
let (app, ids) = fixture();
|
||||
let mut h = controls(app, false);
|
||||
h.get_by_role_and_label(Role::Button, "Create warped text").click();
|
||||
h.run();
|
||||
let dialog = h.state().ui.dialogs.last().unwrap();
|
||||
assert_eq!(dialog.fields.get("__command").and_then(|c| c.as_str()), Some("type.warpText"));
|
||||
let dialog = dialog.id;
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("style".into(), json!("arc"));
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
for id in &ids[..2] {
|
||||
assert!(text(h.state(), *id).warp.is_some(), "a selected layer is warped");
|
||||
}
|
||||
assert!(text(h.state(), ids[2]).warp.is_none(), "an unselected layer is left alone");
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run();
|
||||
assert!(h.get_by_role_and_label(Role::Button, "Create warped text").accesskit_node().is_disabled());
|
||||
}
|
||||
}
|
||||
|
||||
/// #1132: with the Move tool, double-clicking type on the canvas edits it (Photoshop CC 2019+):
|
||||
@@ -2475,3 +2788,85 @@ fn justify_buttons_are_greyed_on_point_text() {
|
||||
h.run_steps(4);
|
||||
assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text");
|
||||
}
|
||||
|
||||
/// The paragraph box's document rect: its text-space origin through the layer transform, then
|
||||
/// width and height. Only what the symmetric-resize tests need (identity linear part).
|
||||
fn box_doc_rect(app: &PhotocraftApp, id: LayerId) -> [f64; 4] {
|
||||
let t = text(app, id);
|
||||
let (x, y, w, h) = match t.shape {
|
||||
photocraft_doc::text::TextShape::Box { x, y, width, height } => (f64::from(x), f64::from(y), f64::from(width), f64::from(height)),
|
||||
ref s => panic!("not a box: {s:?}"),
|
||||
};
|
||||
let p = t.transform.apply(Point::new(x, y));
|
||||
[p.x, p.y, w, h]
|
||||
}
|
||||
|
||||
/// ⌥ on an edge drag of a paragraph box mirrors the opposite edge about the box's centre, so
|
||||
/// the block grows symmetrically; without ⌥ the opposite edge stays put.
|
||||
#[test]
|
||||
fn alt_dragging_a_box_edge_mirrors_the_opposite_edge() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
|
||||
app.ui.extras.snap = false;
|
||||
// The box is at (300, 300), 240×180: its bottom-edge handle is the midpoint (420, 480).
|
||||
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 530.0 }, Modifiers::NONE);
|
||||
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 230.0], "plain drag: the top edge stays");
|
||||
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::ALT);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 580.0, pressure: 1.0 }, Modifiers::ALT);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 580.0 }, Modifiers::ALT);
|
||||
// The centre (300 + 115 = 415) held: the bottom followed the pointer (580), the top
|
||||
// mirrored up to 250.
|
||||
assert_eq!(box_doc_rect(&app, id), [300.0, 250.0, 240.0, 330.0]);
|
||||
}
|
||||
|
||||
/// ⌥ pressed or released mid-drag applies as if it had been held from the start: the mirror
|
||||
/// turns about the centre the box had at the press, not about wherever the box drifted.
|
||||
#[test]
|
||||
fn alt_pressed_mid_drag_mirrors_about_the_press_centre() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
|
||||
app.ui.extras.snap = false;
|
||||
// Bottom edge (420, 480) dragged down 80 px without ⌥: only the bottom moves.
|
||||
tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 520.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 560.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 260.0]);
|
||||
// ⌥ pressed mid-drag: the bottom follows the pointer, the top jumps to the mirror about
|
||||
// the press centre (390): 2*390 − 600 = 180 — not about the drifted one (430).
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::ALT);
|
||||
assert_eq!(box_doc_rect(&app, id), [300.0, 180.0, 240.0, 420.0]);
|
||||
// ⌥ released again mid-drag: the top snaps back to where a plain drag leaves it.
|
||||
tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 300.0]);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 600.0 }, Modifiers::NONE);
|
||||
}
|
||||
|
||||
/// ⌥ mirrors a side-edge drag the same way, and a corner drag grows both axes about the
|
||||
/// centre; clamped, the ⌥ box stops at MIN_BOX about its centre instead of collapsing.
|
||||
#[test]
|
||||
fn alt_mirrors_side_and_corner_drags_and_clamps_at_the_minimum() {
|
||||
use crate::canvas::{ToolEvent, tool_event};
|
||||
let drag = |app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers| {
|
||||
tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods);
|
||||
tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods);
|
||||
tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods);
|
||||
};
|
||||
// Right-edge handle: (540, 390) for the box at (300, 300), 240×180.
|
||||
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
|
||||
app.ui.extras.snap = false;
|
||||
drag(&mut app, [540.0, 390.0], [590.0, 390.0], Modifiers::ALT);
|
||||
// cx = 420 held: the right edge followed to 590, the left mirrored from 300 to 250.
|
||||
assert_eq!(box_doc_rect(&app, id), [250.0, 300.0, 340.0, 180.0]);
|
||||
// Clamped: dragging the right edge past the centre leaves the minimum box about it.
|
||||
drag(&mut app, [590.0, 390.0], [400.0, 390.0], Modifiers::ALT);
|
||||
let r = box_doc_rect(&app, id);
|
||||
assert_eq!(r[2], 4.0, "MIN_BOX wide");
|
||||
assert!(((r[0] + r[2] / 2.0) - 420.0).abs() < 1e-9, "still centred on 420");
|
||||
// Corner handle (bottom-right of the fresh box): ⌥ grows both axes about the centre.
|
||||
let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0));
|
||||
app.ui.extras.snap = false;
|
||||
drag(&mut app, [540.0, 480.0], [590.0, 530.0], Modifiers::ALT);
|
||||
assert_eq!(box_doc_rect(&app, id), [250.0, 250.0, 340.0, 280.0]);
|
||||
}
|
||||
|
||||
+13
-2
@@ -84,8 +84,8 @@ what an agent can't finish alone.
|
||||
- **Estimate:** 80–140 h.
|
||||
|
||||
### G8. Type depth
|
||||
- **Missing:** type on a path beyond rigid glyph placement (TYPE-218-1 partial, #4365),
|
||||
paragraph direction UI (TYPE-218-2), complex-script fallback fonts and golden tests
|
||||
- **Missing:** type on a path beyond rigid glyph placement (TYPE-218-1 partial, #4365; the Type tool's click on a shown path landed),
|
||||
the visual RTL caret (TYPE-218-2; the paragraph direction buttons landed 2026-10-20), complex-script fallback fonts and golden tests
|
||||
(TYPE-218-3), OpenType Numerals and Stylistic Sets submenus
|
||||
(TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), text free transform
|
||||
(#2630). Done since the re-measure: manual and optical kerning with PSD round trip
|
||||
@@ -220,6 +220,9 @@ what an agent can't finish alone.
|
||||
### G9. Panels, preferences and workspace
|
||||
- **Progress (2026-10-15):** Preferences › General › Animated Zoom takes effect: a held Zoom
|
||||
tool press zooms continuously about the press point, ⌥ out (#6210, `ui-egui/src/zoom_hold.rs`).
|
||||
- **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without
|
||||
a type target, editing persisted Type tool defaults inherited by new text. Formatting existing
|
||||
selected text keeps its current command and undo scope. Other panel gaps remain open.
|
||||
- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086),
|
||||
with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`).
|
||||
- **Missing:** 24 of 171 preferences do nothing (scorecard prefs audit, 2026-10-15: Save in
|
||||
@@ -292,6 +295,10 @@ what an agent can't finish alone.
|
||||
as tofu (#1408, #1693).
|
||||
- **Evidence (2026-10-11):** Czech dynamic UI wrappers, hardware labels and composed messages
|
||||
translate at display sites; engine errors remain English. This does not close G13.
|
||||
- **Evidence (2026-10-15):** Russian gained the 1,229 dynamic labels the other catalogs already
|
||||
had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus,
|
||||
metadata, System Info, status templates, dialogs), with a terminology note
|
||||
([localization-ru](localization-ru.md)); engine errors remain English. This does not close G13.
|
||||
- **Detail:** [`localization-parity.md`](localization-parity.md).
|
||||
- **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded).
|
||||
|
||||
@@ -308,7 +315,9 @@ what an agent can't finish alone.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar; TYPE-218-2 partial (the visual RTL caret is still missing) |
|
||||
| 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19); no readiness percentage or estimate changed |
|
||||
| 2026-10-18 | minor | G13: Russian gained the 1,229 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open |
|
||||
| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) |
|
||||
| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
|
||||
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
|
||||
@@ -337,8 +346,10 @@ what an agent can't finish alone.
|
||||
| 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 |
|
||||
| 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 |
|
||||
| 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 |
|
||||
| 2026-10-14 | minor | G9: Character/Paragraph defaults before creating text (#5556); no percentage or estimate changed |
|
||||
| 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed |
|
||||
| 2026-10-13 | minor | G10: Amiga IFF/ILBM read landed (1–8 planes, CMAP, stored and ByteRun1); IFF write remains open |
|
||||
| 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed |
|
||||
| 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed |
|
||||
| 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed |
|
||||
| 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done |
|
||||
|
||||
@@ -44,9 +44,18 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast
|
||||
| HDR Toning | Mappatura tonale HDR | tone mapping; *Viraggio* stays for colour toning (*Viraggio separato*) |
|
||||
| Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) |
|
||||
| Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* |
|
||||
| Screen (blend mode) | Scherma | the blend mode; *Schermo* stays for the display (*Modalità schermo*, *Risoluzione schermo*) |
|
||||
| Dodge / Burn / Sponge (tools) | Scherma / Brucia / Spugna | the *tools*, one family. *Scherma* is also the `Screen` blend mode (Adobe's own collision). *Schiarisci* is the `Lighten` blend mode and is never the Dodge tool |
|
||||
|
||||
## Native review
|
||||
|
||||
2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the
|
||||
word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*):
|
||||
a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend
|
||||
mode and would have given two different things the same name. The wording is pinned by a test
|
||||
(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which
|
||||
also keeps the tool family together.
|
||||
|
||||
2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR
|
||||
Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel &
|
||||
Emboss, East Asian / Middle Eastern Features, linked smart objects, bit depth labels).
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (Russian dynamic labels; previously minor: Spanish dynamic labels) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
|
||||
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
|
||||
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md).
|
||||
[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md).
|
||||
|
||||
**How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key
|
||||
universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog
|
||||
@@ -33,7 +33,7 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
|
||||
| English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h |
|
||||
| Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h |
|
||||
| Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h |
|
||||
| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h |
|
||||
| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | partial | 15–20 h |
|
||||
| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h |
|
||||
| French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h |
|
||||
| Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h |
|
||||
@@ -46,8 +46,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
|
||||
## Other shipped languages
|
||||
|
||||
8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional
|
||||
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
|
||||
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the
|
||||
Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the
|
||||
other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522),
|
||||
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the
|
||||
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
|
||||
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
|
||||
@@ -87,3 +87,5 @@ engine localization or native review is complete.
|
||||
| 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added |
|
||||
| 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed |
|
||||
| 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs |
|
||||
| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test |
|
||||
| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Russian terminology and dynamic UI labels
|
||||
|
||||
This note goes with the completion of the Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`).
|
||||
The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked the
|
||||
labels that only exist at runtime and that other catalogs (Italian, Serbian, Czech, Hungarian)
|
||||
already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects,
|
||||
History / Layers / paths / channels menu labels, metadata fields, status messages, System Info
|
||||
and dialog text. In Russian those showed in English.
|
||||
They are now translated, reusing the vocabulary the catalog already uses elsewhere so the same
|
||||
term reads the same everywhere. Translations are written from the meaning of PhotoCraft's English
|
||||
labels; no proprietary translation resources, fonts, screenshots or user documents are included.
|
||||
|
||||
## Register and style
|
||||
|
||||
Commands are imperatives in the second person plural (*Сохранить*, *Удалить*, *Заново*);
|
||||
where the catalog already uses a noun it is kept (*Уровни…*, *Кривые…*). Sentence case after the
|
||||
first word, as in the rest of the catalog. Ellipses on dialog commands are preserved (`Ветер…`
|
||||
→ `Ветер…`). Decimals use a comma (the file format's own labels keep their dots, e.g.
|
||||
`Landscape, 10 x 8` stays `Альбомная, 10 x 8`). Latin product and technology names are not
|
||||
translated (PhotoCraft, Camera Raw, RGB, CMYK, Lab, LUT, WebP); Camera Raw keeps `.xmp`; the
|
||||
`HDR`, `HSL`, `HSB` and `W3C` acronyms and the `sGray`/`Pro Photo` identifiers stay Latin.
|
||||
|
||||
## Terminology
|
||||
|
||||
Existing choices were followed rather than re-invented, so the new rows use:
|
||||
|
||||
| English source | Russian | Note |
|
||||
|---|---|---|
|
||||
| Highlight(s) | Света | already in the catalog (`Highlights` → `Света`) |
|
||||
| Midtone(s) | Полутона | already in the catalog |
|
||||
| Shadow(s) | Тени | pairs with *Света* |
|
||||
| Feather | Растушёвка | never *перо* |
|
||||
| Fuzziness | Разброс | Color Range's tolerance |
|
||||
| Dither | Дизеринг | |
|
||||
| Pattern | Узор | also in *Узорный штамп* |
|
||||
| Preset | Набор | *Набор кистей*, *набор инструментов* |
|
||||
| Guide | Направляющая | *Схема направляющих* |
|
||||
| Artboard | Монтажная область | |
|
||||
| Workspace | Рабочая среда | |
|
||||
| Stroke (contour) | Обводка | painterly strokes stay *штрихи*/*мазки* |
|
||||
| Stroke (paint param) | Штрихи / мазки | `Strokes` → `Штрихи` |
|
||||
| Amount | Величина | `Штрихи`, never *количество*, when it is a strength |
|
||||
| Fill Opacity | Непрозрачность заливки | distinct from layer *Непрозрачность* |
|
||||
| Feather Radius | Радиус растушёвки | |
|
||||
| Sharpen | Резкость | `Smart Sharpen` → *Умная резкость*, `Unsharp Mask` → *Контурная резкость* |
|
||||
| Noise | Шум | `Reduce Noise` → *Уменьшить шум* |
|
||||
| Content-Aware | С учётом содержимого | |
|
||||
| Camera Raw | Camera Raw | the panel name stays Latin (as German/Spanish do; Greek translates it) |
|
||||
| Place Embedded / Linked | Поместить встроенный / связанный | |
|
||||
| Rasterize | Растрировать | |
|
||||
|
||||
## Native review
|
||||
|
||||
The review was AI-assisted and each row was checked against the meaning of PhotoCraft's own
|
||||
English label. A review by a native speaker is still welcome.
|
||||
|
||||
## Not covered
|
||||
|
||||
Engine error and status messages, user document, layer and preset names, and automation command
|
||||
IDs stay in English, as in every language ([`localization-parity.md`](localization-parity.md)).
|
||||
A few dynamic labels the other catalogs carry are not used by the current code; they are
|
||||
translated to stay aligned with the other catalogs.
|
||||
|
||||
## Validation
|
||||
|
||||
- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`)
|
||||
enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires
|
||||
a Russian row for each, like the Korean and Italian tests.
|
||||
- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`)
|
||||
covers the rasterize prompts, hardware status lines, composed status templates and a sample of
|
||||
filter labels.
|
||||
- `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed.
|
||||
- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys.
|
||||
- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian
|
||||
labels, without missing glyphs or clipped text.
|
||||
@@ -28,6 +28,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
|
||||
| Magyar | `hu` | one form |
|
||||
| Tiếng Việt | `vi` | one form |
|
||||
| Srpski | `sr` | one / few / many (teens use many) |
|
||||
| हिन्दी | `hi` | one / other |
|
||||
|
||||
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
|
||||
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
|
||||
|
||||
+1
-1
@@ -215,7 +215,7 @@ Unread: `general.color_picker`, `general.zoom_resizes_windows`, `workspace.auto_
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) |
|
||||
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text) |
|
||||
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
|
||||
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) |
|
||||
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet |
|
||||
|
||||
@@ -213,6 +213,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
|
||||
| Behaviour | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Character and Paragraph controls without a type target | done | #5556; persistent Type tool defaults feed new point, box and mask text; existing text still edits in its selected scope (`type_tool.rs`, `type_tool_tests.rs`) |
|
||||
| Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 |
|
||||
| Drag panels to reorder and regroup | partial | #2272, #2305 |
|
||||
| Docked resize pushes the neighbour | partial | #2573 |
|
||||
@@ -242,6 +243,7 @@ Paragraph; the rest open as in-app floating windows.
|
||||
| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) |
|
||||
| 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) |
|
||||
| 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial |
|
||||
| 2026-10-14 | minor | Character and Paragraph panels edit persistent defaults without a type target (#5556); no percentage or estimate changed |
|
||||
| 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |
|
||||
| 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped |
|
||||
| 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues |
|
||||
|
||||
+3
-3
@@ -9,14 +9,14 @@ id = "TYPE-218-1"
|
||||
target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)"
|
||||
note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text)"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-2"
|
||||
target = "Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret"
|
||||
status = "missing"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "Engine has TextDirection (text.rs:155); no UI"
|
||||
note = "Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order)"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-3"
|
||||
|
||||
Reference in New Issue
Block a user