Merge track r2-t04 into next

# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/target-app-parity.md
#	docs/whats-new.md
This commit is contained in:
aiongg committed 2026-10-22 02:53:42 +00:00
commit 8daa25c4c9
53 files changed
+13620 -100

No files matched your search

+3 -2
View File
@@ -14,7 +14,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
@@ -452,7 +453,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General →
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული, Svenska, Suomi) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
+2 -1
View File
@@ -14,7 +14,8 @@
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a>
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
+629
View File
@@ -0,0 +1,629 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">LightCraft</h1>
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
<p align="center">
<a href="README.md">English</a> ·
<a href="README.zh-hans.md">简体中文</a> ·
<a href="README.zh-hant.md">繁體中文</a>
</p>
<p align="center">
<b>照片庫與 raw 顯影;以淨室(clean-room)方式、用純 Rust 重新實作的開源 Adobe Lightroom。</b><br>
在 macOS、Windows 與 Linux 上原生執行,也能透過 WebAssembly 在瀏覽器中使用,AI 代理也能透過 MCP 全程操作。
</p>
<p align="center">
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/lightcraft"><b>getartcraft.com 上的 LightCraft</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">所有 Crafting Apps</a>
</p>
<br>
<p align="center">
<img src="docs/images/hero-tetons.jpg" alt="LightCraft 的編輯檢視畫面:放大檢視中是安瑟·亞當斯的《提頓山脈與蛇河》,右側開啟著「光線」與「效果」面板,底片顯示窗格中是四張展示照片" width="100%">
<br>
<sub><i>安瑟·亞當斯,《提頓山脈與蛇河》(1942)。公眾領域,美國國家檔案館。於 LightCraft 中顯影。</i></sub>
</p>
> [!NOTE]
> **ArtCraft 是一個由各行各業的創作者組成的社群。** 數位藝術、生成藝術、音樂、
> 遊戲 &mdash; 只要你在創作,你就是我們的一員。**[來 Discord 打聲招呼吧](https://discord.gg/artcraft)。**
<p align="center">
<a href="#edit-like-you-mean-it">編輯</a> ·
<a href="#color-grading-the-cinematic-way">色彩分級</a> ·
<a href="#before--after">編輯前/後對比</a> ·
<a href="#masking-that-goes-where-you-point">遮色片</a> ·
<a href="#presets-profiles--the-color-mixer">預設集</a> ·
<a href="#organize-everything">照片庫</a> ·
<a href="#built-for-agents">代理與 MCP</a> ·
<a href="#fast-native-private">效能</a> ·
<a href="#feature-status">功能狀態</a> ·
<a href="#quick-start">快速開始</a> ·
<a href="ROADMAP.md">路線圖</a> ·
<a href="#downloads">下載</a> ·
<a href="#the-crafting-apps">Crafting Apps</a>
</p>
<br>
## Edit like you mean it
LightCraft 是裝在單一原生應用程式裡的完整暗房。每一項調整都是**非破壞性**的,永遠不會動到原始檔案。
每個滑桿都經過一條**場景參考、寬色域、32 位元浮點的管線**算繪:亮部像底片一樣柔和過渡,陰影提亮卻不產生光暈,
色彩從拍攝到匯出始終保持乾淨。
<table>
<tr>
<td width="50%" valign="top">
### ☀️ 光線
**曝光、對比、亮部、陰影、白色色階、黑色色階**,搭配邊緣感知的局部色調映射(對對數亮度做引導式濾波)。
把亮部拉到 −100,就能救回一片過曝的天空,而且不會出現用簡單曲線時常見的灰色光暈。
### 🎨 色彩
依**色溫**與**色調**調整**白平衡**(raw 用 K 值,JPEG 用相對值),附帶預設、自動,以及點一下就能中和的**滴管**。
保護膚色的**自然飽和度**、**飽和度**、8 段式**混色器**(色相/飽和度/明度),以及附帶混合與平衡的
三區**色彩分級**色輪。所有運算都在 OkLCh 這個現代感知色彩空間中完成。
</td>
<td width="50%" valign="top">
### ✨ 效果
讓細節更細緻的**紋理**、讓中間調更有力的**清晰度**、**去朦朧**(暗通道先驗加引導式細化;
推到負值可增添氛圍感)、裁切後附帶亮部優先、圓度與羽化的**暗角**,以及解析度無關、可調大小與粗糙度的
底片**顆粒**。
### 📈 色調曲線
可移動分界點的參數曲線(區域曲線),**再加上** RGB、紅、綠、藍的點曲線。曲線在結構上就是單調的,
所以不會意外出現色調反轉。
</td>
</tr>
</table>
<p align="center">
<img src="docs/images/curve-tetons.jpg" alt="「光線」面板下方開啟的色調曲線,RGB 點曲線上是一條平緩的 S 形曲線,套用在《提頓山脈與蛇河》上" width="100%">
<br>
<sub>RGB 點曲線上一條平緩的 S 形曲線,就在「光線」滑桿正下方。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub>
</p>
<br>
## Color grading, the cinematic way
用可隨處拖曳的色輪,分別為陰影、中間調與亮部做分離色調。下圖中,桃樂絲·蘭格的
*《移民母親》* 只拖曳兩次,就調出溫暖、如沖印照片般的色調(亮部 42°,陰影 28°)。
<p align="center">
<img src="docs/images/grading-migrant-mother.jpg" alt="中間調、陰影與亮部的色彩分級色輪,旁邊是桃樂絲·蘭格的《移民母親》,調成溫暖的沖印色調" width="100%">
<br>
<sub>「色彩」面板中的陰影、中間調與亮部色輪。<i>桃樂絲·蘭格,《移民母親》(1936)。公眾領域,美國國會圖書館。</i></sub>
</p>
<br>
## Before & after
按住 <kbd>\\</kbd> 偷看原圖,按 <kbd>Y</kbd> 並排比較,或按 <kbd>Shift</kbd>+<kbd>Y</kbd> 分割檢視。
下面每一張都是 LightCraft 的真實截圖,由代理透過[控制通道](#built-for-agents)自動擷取。
<table>
<tr>
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="《提頓山脈與蛇河》的並排編輯前/後對比:編輯後的雲層更深,河岸邊的暗部更通透"><br><sub><b>《提頓山脈與蛇河》。</b> 亮部 −45、陰影 +38、清晰度 +28、去朦朧 +18。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="《移民母親》的並排編輯前/後對比:編輯後更溫暖,陰影被提亮"><br><sub><b>《移民母親》。</b> 陰影 +42、紋理 +18、分離色調分級、暗角。<i>桃樂絲·蘭格,1936(公眾領域)。</i></sub></td>
</tr>
<tr>
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="《地出》的並排編輯前/後對比:月球地平線上方是地球,編輯後略暖、更濃郁"><br><sub><b>《地出》。</b> 去朦朧 +22、亮部 −30、更暖的白平衡、自然飽和度 +22。<i>NASA/比爾·安德斯,阿波羅 8 號,1968(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="《藍色大理石》的並排編輯前/後對比:編輯後黑色更深,雲層細節更扎實"><br><sub><b>《藍色大理石》。</b> 亮部 −38、黑色色階 −20、去朦朧 +15、自然飽和度 +30。<i>NASA,阿波羅 17 號,1972(公眾領域)。</i></sub></td>
</tr>
</table>
<br>
## Masking that goes where you point
用**筆刷**塗抹(大小、羽化、流量、濃度、擦除),放置附可拖曳控制點的**線性漸層**與**放射狀漸層**,
或依**明度範圍**、**色彩範圍**、**天空**、**主體**與**背景**來選取。用**新增/減去/交集**組合各個元件,
反轉其中任何一個,每個遮色片可設定 15 項局部調整(色溫、色調、曝光、對比、亮部、陰影、白色色階、黑色色階、紋理、
清晰度、去朦朧、色相、飽和度、銳利化、雜訊抑制),再加上一個整體的強度。
<p align="center">
<img src="docs/images/masking.jpg" alt="遮色片面板中有一個線性天空遮色片與一個放射狀陽光光暈遮色片;放射狀漸層在湖景照片上以紅色疊加層畫在太陽周圍" width="100%">
<br>
<sub>疊加在線性「天空」遮色片之上的放射狀「陽光光暈」遮色片(色溫 +40、曝光 +0.50)。<i>照片來自 LightCraft 程式產生的示範照片庫。</i></sub>
</p>
<br>
## Presets, profiles & the Color Mixer
內建十八個手工打造的預設集(*黃金時刻、青綠與橙色、褪色霧面、硒調色、清晰風景*等),每個都有 0 到 200 % 的**強度**滑桿。你可以從任意一組設定儲存自己的預設集、加入喜好項目,
並在整個選取範圍中,只複製、貼上或同步你選定的那些群組。
<p align="center">
<img src="docs/images/presets-mixer.jpg" alt="預設集欄依黑白、色彩、底片、風景、人像與樣式分組,旁邊是「色彩」面板,8 段式混色器開啟在紫色上" width="100%">
<br>
<sub>預設集欄與 8 段式混色器並排。<i>照片來自示範照片庫。</i></sub>
</p>
<table>
<tr>
<td width="50%" valign="top">
### ✂️ 裁切、拉直與幾何
自由或鎖定的長寬比(1:1、4:5、5:7、2:3、4:3、16:9、16:10、原始比例),拖曳旋轉拉直時會始終把最大的裁切框
保持在畫面內,三分法/格線/黃金比例裁切疊加,翻轉與 90° 旋轉,再加上手動的垂直、水平、旋轉、長寬比、
縮放與偏移變換。
</td>
<td width="50%" valign="top">
### ⚫️ 黑白
一鍵(<kbd>V</kbd>)轉為單色,搭配 8 段式**黑白混色**,可壓暗天空或讓樹葉發亮。
之後再用色彩分級調出硒調色、深褐色調或分離色調的沖印效果。
</td>
</tr>
<tr>
<td width="50%"><img src="docs/images/crop-tetons.jpg" alt="《提頓山脈與蛇河》上的裁切工具:一個略微旋轉的 16:9 裁切框,附三分法裁切疊加,右側是幾何滑桿"><br><sub>一個 16:9 的裁切框拉直了 +2.5°,附三分法裁切疊加。<i>安瑟·亞當斯,1942(公眾領域)。</i></sub></td>
<td width="50%"><img src="docs/images/bw-split.jpg" alt="沙丘的編輯前/後分割檢視:左邊是彩色,右邊是附顆粒與暗角的黑白"><br><sub>分割檢視:左邊是彩色原圖,右邊是附清晰度、暗角與顆粒的黑白。<i>示範照片庫。</i></sub></td>
</tr>
</table>
<br>
## Organize everything
一個不擋路的照片庫:**所有照片**、**最近加入**、**精選照片**、**依日期**、以巢狀收在**資料夾**裡的**相簿**,
以及**最近刪除**。用 <kbd>0</kbd>–<kbd>5</kbd> 評分,用 <kbd>P</kbd> / <kbd>X</kbd> /
<kbd>U</kbd> 加旗標,用 <kbd>6</kbd>–<kbd>9</kbd> 加色彩標籤。搜尋支援欄位語法:
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains`。每個檢視都能依拍攝日期、
匯入日期、編輯日期、名稱、評分、大小或隨機排序(一種穩定的亂序;「檢視 → 排序 → 重新隨機排序」可換一個順序)。
左右對齊的**照片格狀檢視**與**方格檢視**都經過虛擬化處理,
所以無論是四十張還是四萬張照片都一樣流暢。
<table>
<tr>
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="24 張示範照片的左右對齊照片格狀檢視,附評分與旗標,側邊欄是 Travel 2026 資料夾下的巢狀相簿"><br><sub>照片格狀檢視,相簿以巢狀收在資料夾裡,附評分與旗標。<i>示範照片庫。</i></sub></td>
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="方格檢視,顯示四張公眾領域展示照片及其星級評分與精選旗標"><br><sub>四張公眾領域展示照片的方格檢視。</sub></td>
</tr>
<tr>
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="《地出》的資訊面板,顯示檔名、2400 × 2400 的 JPEG 尺寸、評分、標題欄位與相機中繼資料"><br><sub>資訊面板:檔案、尺寸、評分、標題、圖說、著作權與相機中繼資料。<i>NASA/比爾·安德斯,《地出》,阿波羅 8 號,1968(公眾領域)。</i></sub></td>
</tr>
</table>
<br>
## Built for agents
LightCraft 裡的每一個選單項目、滑桿、筆刷筆觸、裁切控點與按鍵,都是一個擁有穩定 ID 與
JSON 參數的**指令**。介面、鍵盤、CLI、JSON-lines 控制通道與一個 **MCP 伺服器**全都透過同一個入口分派。
代理可以挑片、顯影、為天空加上遮色片並匯出,而且能*看到*結果。
```sh
lightcraft --control 7980 ~/Pictures/trip
```
```jsonc
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // 依名稱操作任意控制項
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
{"method": "ui.screenshot", "params": {"path": "after.png"}}
```
- **指令註冊表。** `engine.commands` 列出可用的指令;`develop.controls` 列出每個滑桿的範圍、
預設值與目前值。
- **MCP 伺服器。** `lightcraft-cli mcp` 把指令註冊表開放給 Claude(或任何 MCP 用戶端),
同時提供匯入、查詢、顯影、遮色片、算繪(以影像回傳)與匯出的輔助工具。它可以無介面執行,也可以附掛
到正在執行的應用程式上,支援截圖、點擊與手勢。詳見 [docs/mcp.md](docs/mcp.md)。
```sh
cargo build --release -p lightcraft-cli
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # 無介面
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # 操作執行中的應用程式
```
- **可腳本化的 CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
path=out.jpg longEdge=2048` 可執行任意指令鏈(無介面、對已儲存的照片庫,或對執行中的應用程式),
每條指令印出一行 JSON 結果;`lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`。
- **一切皆可復原**,包括代理的操作:一次滑桿拖曳(或腳本中一連串的更新)算一步復原。
- **縮圖目錄(原生應用程式/CLI):** 選取照片,然後 **檔案 → 縮圖目錄 PDF…**。可選擇 A4 或 Letter、橫向、
列數與欄數,以及檔名圖說;儲存分頁 PDF,再用你的 PDF 檢視器列印。匯出會套用目前的編輯,每張照片完整放入
而不再次裁切,以 150 dpi 和 sRGB 算繪。背景匯出支援取消,並保護原始檔案與 XMP 附屬檔案。從 CLI 呼叫:
```sh
lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true
```
`export.contactSheet` 也接受明確的 `ids`、`landscape=true` 和 `paper=letter`。只有在整份 PDF 成功產生之後,
它才會取代既有的輸出檔案。限制:1,000 張照片、100 頁、256 MiB;圖說使用與浮水印相同的字型涵蓋範圍來點陣化
(CJK 文字需要 craft-fonts),過長的名稱會在中間省略。
這是一套縮圖目錄工作流程,
不是印表機驅動程式或自訂拼版編輯器。
- **列印版面(原生應用程式/CLI):** **檔案 → 列印…** 會開啟「列印」模組的版面編輯器:單張照片、
縮圖目錄、相片包(自動版面會把格子排進頁面)與自訂相片包(拖曳並調整格子大小、堆疊順序、對齊格子或
格線),縮放填滿/旋轉以符合、相片邊框與內框線,紙張尺寸從 4 × 6 到 13 × 19 以及 A3 到 A5 或自訂,
邊界、以英吋/公分/公釐/點為單位的尺規、頁面背景、72–720 ppi,以及已儲存的範本。**列印為 PDF…**
會在背景算繪。一切都是 `print.*` 指令
(`print.get`、`print.set`、`print.cell.add`、`print.layout.autoLayout`、`print.exportPdf`、
`print.template.save`……),所以 CLI 和 MCP 也能操作它:
```sh
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
印表機色彩管理與頁面疊加層(身分標識、浮水印、頁碼)目前還沒有。
- **每個控制項都可定址**(`ui.widgets`)且可依名稱點擊,所以代理操作的是真實介面,而不是走旁門。
- 本 README 中的截圖全部由 [`docs/showcase/`](docs/showcase/) 腳本全自動產生。
協定參考:[docs/control-protocol.md](docs/control-protocol.md)。
<br>
## Fast, native, private
- **純 Rust,無 C。** 我們自己的 RAW 解碼器(DNG、Canon CR2/CR3、Sony ARW、Nikon NEF、Fujifilm RAF 含 X-Trans、
Panasonic RW2/Leica RWL、Pentax PEF、Olympus ORF),我們自己的色彩科學,我們自己的管線。JPEG、PNG、TIFF、WebP、
PSD 合成圖與 JPEG XL 現在就能開啟;加上 `--features heif` 還能開啟 HEIC/HEIF 照片(HEVC 於建置時選用,
與 PhotoCraft 中一致)。
- **場景參考與寬色域。** 內部使用線性 Rec.2020 浮點、Bradford 色彩適應的白平衡、以色域映射取代裁剪,
raw 走底片式肩部曲線,未經改動的 JPEG 則採像素級直通。
- **解析度無關的編輯。** 半徑與筆刷大小都相對於影像,所以 400 px 的預覽、你的 5K 顯示器和 60 MP 的匯出
看起來完全一致。
- **GPU 加速,與 CPU 結果一致。** 整條顯影管線都以 wgpu 計算核心執行(Metal/Vulkan/DX12),
並與 CPU 管線比對到 1/255 以內的誤差。在 24 MP 的 raw 上(Apple M4 Pro):一次滑桿更新約 4 ms 重新算繪,
冷啟動的 2.5 MP 放大檢視約 30 ms,全尺寸匯出約 0.3 秒(含平行 JPEG 編碼)。
沒有 GPU 時,同一條管線會在所有 CPU 核心上執行,只重做受滑桿影響的那些階段。批次匯出會同時算繪多張照片,
所以一張的解碼與編碼能和另一張的算繪重疊。
- **即時挑片。** 開啟一張 raw 會在約 0.1 秒內顯示其內嵌的相機預覽或快取算繪結果,完整算繪隨後跟上
(24 MP 約 0.2–0.5 秒)。上一張與下一張會在背景準備好,因此瀏覽整批照片時每張約 50 ms。
- **背景算繪。** 一個工作執行緒池在 UI 執行緒之外算繪放大檢視、編輯前/後對比與每一個可見縮圖:
拖曳時用草稿品質,放開後以完整品質算繪。
- **本機優先。** 沒有帳號、沒有雲端、沒有遙測、沒有訂閱。你的目錄是一份只會附加寫入、可讀的操作紀錄,
可以比對差異、備份或重播。
<br>
## Feature status
LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,我們目前的進度是**(細節見[路線圖](ROADMAP.md)和
[docs/target-app-parity.md](docs/target-app-parity.md)):
- **依功能數量計算,約達 Lightroom 的 81%**(核心功能 98%),逐行記錄在
[docs/parity-checklist.md](docs/parity-checklist.md)。
- **作為日常取代 Lightroom 的工具,整體產品更接近 61%,對一般全職攝影師約 51%,一般休閒用途約 65%。**
在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
- **最大的缺口:**
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
- **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
- **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- **螢幕上的 HDR 顯示、影片,以及 Lightroom Classic 的列印、書冊與地圖模組。**
- **接下來做什麼:** 見[依影響力排序的缺口](docs/gaps.md)和[路線圖](docs/roadmap.md)。
| 領域 | 狀態 |
|---|---|
| 照片庫:相簿、資料夾、智慧型相簿、堆疊(含自動堆疊)、虛擬副本、評分、旗標、標籤、篩選列、搜尋、排序、格狀檢視、底片顯示窗格 | ✅ |
| 挑片:比較(同步縮放)與總覽檢視、自動前進、即時預覽 | ✅ |
| 光線、色彩、效果(暗角樣式)、色調曲線(+ 細化飽和度、目標調整)、混色器(+ 目標調整)、點色彩、色彩分級、校正、黑白 | ✅ |
| 遮色片:筆刷、線性、放射狀、明度/色彩範圍、新增/減去/交集 | ✅(AI 主體/天空目前使用傳統啟發式演算法) |
| 裁切、拉直工具 + 自動拉直、翻轉、旋轉、長寬比、裁切疊加參考線 | ✅ |
| 描述檔(色彩、中性、鮮豔、風景、人像、單色:我們自己的風格)、預設集、版本、歷史紀錄、複製/貼上/同步設定 | ✅ |
| 相機色彩:DNG 檔案使用自帶矩陣 | ✅ DNG · 🚧 自家 Sony/Fujifilm 描述檔;缺少實測校正資料庫 |
| 原生 macOS 選單列(由指令註冊表產生)、控制通道 + 每個控制項可定址、無介面 UI 快照 | ✅ |
| RAW:DNG、CR2、CR3(無損 CRX Bayer 以及版本 0x100/0x200 的 C-RAW)、ARW、NEF(未壓縮 + 無損/有損壓縮,含有損壓縮經分割後的格式)、Fujifilm RAF(未壓縮 + 無損/有損壓縮,Bayer + X-Trans)、Panasonic RW2/Leica RWL/Panasonic RAW(所有 raw 格式,從 DMC-LX1 到 DC-S1RM2)、Pentax PEF、Olympus ORF(未壓縮,12.8 bpp)、Samsung SRW(未壓縮);所有格式皆支援內嵌預覽,含 CR3 | 🚧 · 其他 CR3 變體、壓縮 ORF/SRW 和 Nikon HE NEF 解碼 ⬜([raw 相容度](docs/raw-parity.md)) |
| 細節:銳利化、明度 + 色彩降噪 | ✅ · AI 降噪 🚧(Bayer,使用者自行安裝權重)、超解析度 ⬜ |
| 斑點移除/修復/仿製(自動取樣來源)、顯示斑點、紅眼與寵物眼校正(自動瞳孔偵測、眼神光) | ✅ · 內容感知填色、斑點圖釘編輯 🚧 |
| 匯出:JPEG/PNG/TIFF/WebP/AVIF/DNG/原始格式,尺寸調整、檔案大小上限、輸出銳利化、命名範本、批次處理、中繼資料政策、文字或影像浮水印 | ✅ |
| HDR:附餘裕上限的 HDR 編輯、SDR 呈現、視覺化 HDR;匯出為 ISO 21496-1 增益圖 JPEG、PQ AVIF 或 32 位元浮點 TIFF | ✅ · HDR 顯示 ⬜ |
| 照片庫持久化(防當機的操作紀錄 + 快照、背景壓縮、儲存失敗會回報)、磁碟縮圖快取 | ✅ |
| 匯入:原位置加入/複製/移動、重新命名與資料夾範本、裝置、重複檢測、監看資料夾;本機資料夾瀏覽 | ✅ |
| MCP 伺服器(無介面或執行中的應用程式、持久化照片庫)、CLI、控制通道 | ✅ |
| XMP 附屬檔案(讀取/寫入、自動寫入)、讀取 `crs:` 顯影設定、預設集檔案(`.lcpreset`、XMP 預設集) | ✅ |
| 鏡頭校正(變形、暗角、自動 + 手動色差、去除邊緣色差、內嵌於 DNG 檔案的鏡頭校正資料、Panasonic/Leica RW2/RWL 變形資料以及 Nikon Z 6II/Z 8/Z f 變形資料)、幾何(變換、限制裁切範圍)、透視校正(自動/水平/垂直/完整/引導式) | ✅ · 相機鏡頭描述檔(我們自己的)⬜ |
| 照片合併:HDR(自動對齊、去除殘影)、全景(球面/圓柱/透視、邊界變形、自動裁切)、HDR 全景 → DNG | ✅ |
| GPU 管線(wgpu 計算,與 CPU 一致到 1/255 以內)、裝置限制/發生錯誤時改用 CPU | ✅ · 瀏覽器中的 WebGPU 🚧 |
| AI:分割遮色片、AI 降噪、超解析度、人臉;影片 | 🚧(見[缺口清單](docs/gaps.md)) |
| 網頁版(透過 WASM 在瀏覽器中使用同一套介面):照片庫持久化於 OPFS/IndexedDB、Web Worker 算繪、匯出下載 | ✅ · WebGPU、Safari/Firefox 測試 🚧 |
<sub>✅ 現已可用 · 🚧 進行中 · ⬜ 尚未開始</sub>
<br>
## Quick start
建置整個工作區(含桌面、網頁與 CLI 應用程式)需要 Rust 1.95 或更新版本。
```sh
git clone https://github.com/storytold/lightcraft && cd lightcraft
cargo run --release -p lightcraft # 開啟你的照片庫(~/Pictures/LightCraft Library;新照片庫會先帶示範照片)
cargo run --release -p lightcraft -- ~/Pictures/trip # 匯入你的照片(掃描資料夾,跳過重複項目)
cargo run --release -p lightcraft -- --memory # 用完即丟的記憶體示範工作階段(不寫入任何內容)
cargo run --release -p lightcraft -- --control 7980 # 附帶自動化通道
cargo xtask web --serve # 在瀏覽器中執行同一個應用程式:http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
cargo xtask ci # fmt、clippy、測試、分層、wasm 檢查
```
CI 預設使用行表除錯資訊,並依啟動時可用記憶體每 1.5 GB 開一個建置工作
(每個 CPU 最多一個;讀不到記憶體時為 4 個),所以一台 8 GB、剩餘 6 GB 可用的機器會用 4 個任務建置,
而一台忙碌、剩餘記憶體很少的機器會用更少。測試執行緒數隨之調整,上限為 4。若不限制,每個 CPU 各跑
一個帶完整除錯資訊的連結器,測試還沒開始記憶體就會耗盡。GPU 測試的涵蓋範圍不受影響。明確設定
`CARGO_PROFILE_DEV_DEBUG`、`CARGO_BUILD_JOBS` 與 `RUST_TEST_THREADS` 會覆寫這些預設值。
日常開發指令保留各自的設定。
**記憶體受限機器上的 release 建置:** [#1196](https://github.com/storytold/lightcraft/issues/1196)
回報在一台 16 GB 的 Arch/AUR 機器上,同時建置桌面應用程式與 CLI,並啟用完整除錯資訊、thin LTO
及單一程式碼產生單元時,連結過程遭到 OOM 終止。上述 CI 預設值並不適用於一般的
`cargo build --release`。建議先用一個建置工作;若記憶體充足,可嘗試兩個:
```sh
CARGO_BUILD_JOBS=1 cargo build --release -p lightcraft -p lightcraft-cli
# 或者:CARGO_BUILD_JOBS=2 cargo build --release -p lightcraft -p lightcraft-cli
```
如果打包環境啟用了完整除錯資訊或較耗記憶體的 LTO 設定,可以使用僅對本條指令生效的
覆寫設定,降低原始碼建置所需的記憶體:
```sh
CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release -p lightcraft -p lightcraft-cli
```
這些 POSIX shell 賦值只影響這一條指令,不會變更全域 Cargo 設定或儲存庫的 release profile。
停用 LTO 可能改變效能與二進位檔大小;停用除錯資訊會減少可用的除錯細節。這是建置建議,
並不保證任何機器都有足夠的記憶體。下游打包者應逐一建置大型二進位檔、不要同時進行,並避免在低記憶體
的建置機器上同時使用完整除錯資訊、LTO 與單一程式碼產生單元。自行匯出 profile 設定的
PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替
`makepkg` 加上環境變數前綴就能優先生效。
測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」
這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因,
執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項
跳過的原因。它會寫入 `target/ci/report.json` 和 `target/ci/summary.md`,後者是可直接貼進 PR 的
Markdown 區塊(`--summary-md` 會把它印出來);`--hardware` 還會在每個有實體配接器的後端上執行
GPU 測試。不會上傳任何內容。
**中文與日文文字**需要共用字型儲存庫,這是一個可選的建置輸入(所有正式發布版都會包含它):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
不指定它時,LightCraft 一樣能建置與執行,只是中文和日文文字沒有字形。字型從不提交到
本儲存庫,字型的收錄規範記載在該儲存庫:見 [craft-fonts README](https://github.com/storytold/craft-fonts#readme)。
**介面語言:** **編輯 → 語言**(English、简体中文、繁體中文(台灣)、日本語、Português (Brasil)、Español、Deutsch、Русский、Français、Українська、Čeština、ქართული)或 **設定 → 一般 →
語言**;選擇後立即生效並會保留。詳見 [docs/localization.md](docs/localization.md)。
**記錄檔:** 桌面應用程式會把記錄檔寫到標準錯誤輸出,以及設定資料夾裡的 `logs/lightcraft.log`
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`,預設為 `~/.config/lightcraft/logs/`;macOS
`~/Library/Application Support/LightCraft/logs/`;Windows `%APPDATA%\LightCraft\logs\`),絕不會寫進照片庫裡。
**可攜模式:** 在執行檔旁放一個名為 `portable.txt`(或 `LightCraft.portable`)的檔案,會把設定
資料夾移到執行檔旁的 `LightCraftData`。從桌面選單或 Dock 啟動沒有終端機,所以請把這個檔案附在
錯誤回報裡(**說明 ▸ 開啟記錄資料夾** 會在檔案管理員中顯示它)。每次啟動會把
上一個記錄檔移到 `lightcraft.1.log`,再上一個移到 `lightcraft.2.log`,這樣當機那次的記錄檔就能在下次啟動後
保留下來;檔案到 16 MiB 就會停止增長,`--version` 和 `--help` 不會寫記錄檔,帶
`LIGHTCRAFT_NO_PREFS` 執行時只會寫到標準錯誤輸出。預設情況下,LightCraft 自己的 crate 會記錄到 `info`
層級,其他一切則記錄到 `warn`。`LIGHTCRAFT_LOG=info` 或 `debug` 的行為和以前一樣(LightCraft 自己的 crate
用該層級,其餘只記警告與錯誤;其他值:只記警告與錯誤),並且優先於 `RUST_LOG`;未設定 `LIGHTCRAFT_LOG` 時,`RUST_LOG` 會把預設值換成
env_logger 風格的指令,例如 `RUST_LOG=debug` 或 `RUST_LOG=warn,lightcraft_pipeline=trace`(以
`*` 結尾的指令會涵蓋所有以它開頭的目標,如 `lightcraft*=debug`)。panic 一樣會記錄在那裡,
並且仍然會寫在暫存資料夾裡的 `lightcraft-panics.log`。記錄器程式碼位於 `apps/lightcraft/src/logging.rs`。
網頁版需要 `wasm32-unknown-unknown` target 以及對應版本的 `wasm-bindgen` CLI
(`cargo xtask web` 會印出確切的安裝指令);詳見 [docs/web.md](docs/web.md)。
**Nix** 會建置桌面應用程式與 `lightcraft-cli`(Nix 建置永遠包含 craft-fonts 輸入,所以日文
文字有字形):
```sh
nix run github:storytold/lightcraft # 桌面應用程式
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
nix develop github:storytold/lightcraft # rust 工具鏈 + 原生相依套件 + 字型
```
在 NixOS 上,請從 `nix develop` 執行 `cargo` 建置:在它之外,應用程式找不到 `libvulkan.so.1`,
偵測不到 GPU,會改用 CPU 算繪(啟動時會印出一次提示,說明這樣會慢很多)。`nix run` 和 `nix build`
會自行設定好路徑。
在 flake 設定中(NixOS、home-manager、nix-darwin):
```nix
# flake.nix
inputs.lightcraft.url = "github:storytold/lightcraft";
# 選用:使用你自己的 nixpkgs,而不是 LightCraft 鎖定的那個
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
# 然後在 NixOS 或 home-manager 模組中(`inputs` 在作用範圍內):
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # 讓 `pkgs.lightcraft` 可用
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager:home.packages = [ pkgs.lightcraft ];
```
`nix build` 會安裝與 .deb/.rpm 相同的 desktop 檔案、hicolor 圖示和 AppStream 中繼資料,並執行
`cargo test --workspace` 作為其檢查階段(用 `pkgs.lightcraft.overrideAttrs { doCheck = false; }` 跳過)。
**鍵盤:** <kbd>G</kbd> 格狀檢視 · <kbd>D</kbd> 詳細檢視 · <kbd>E</kbd> 編輯 · <kbd>C</kbd> 裁切 · <kbd>M</kbd> 遮色片 ·
<kbd>Shift</kbd>+<kbd>P</kbd> 預設集 · <kbd>\\</kbd> 原圖 · <kbd>Y</kbd> 編輯前/後 · <kbd>Z</kbd> 縮放 ·
<kbd>J</kbd> 顯示溢色 · <kbd>⌘Z</kbd> 復原 · <kbd>⌘/</kbd> 全部快速鍵。
## How it's built
一個引擎優先的 Cargo 工作區,由一群小而經過測試的 crate 組成,並強制分層(`cargo xtask layers`):`geom`、
`color`、`raster`、`tiff` → `raw`、`codecs`、`meta`、`develop` → `pipeline` → `catalog` → `engine` → `ui-egui`。
egui 前端是一個可替換的 crate;它之下沒有任何東西知道 UI 的存在。`mcp` 和各個應用程式(`lightcraft`、
`lightcraft-cli`)則坐落在 `engine` 之上。
## Contributing
人類與代理遵循同一套規則,所以請先讀 [AGENTS.md](AGENTS.md)。重點如下:
- **淨室。** 絕不閱讀 Adobe 的二進位檔或 GPL 的 raw/照片程式碼(darktable、RawTherapee、LibRaw、rawspeed、dcraw……);
只依據公開規格與黑盒觀察來工作。
- **絕不使用 Adobe 資產:** 不使用 Adobe 產品的圖示、截圖、預設集、描述檔、LUT 或字型。儲存庫中的每一張
影像、圖示和字型都是原創、公眾領域、創用 CC、OFL 或寬鬆授權的,並在同一次提交中
於 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 新增條目。新的字型要提交到
[storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是這裡。
- **純 Rust**、強制的 crate 分層、一切皆指令,並且每次提交前 `cargo xtask ci` 都必須通過
(一次提交對應一個任務 ID)。
- **絕不當機。** 非測試程式碼要回傳錯誤而不是 panic:不使用 `unwrap()`、`expect()`、`panic!` 或
`unsafe`,對任何來自輸入的資料都做受檢的索引存取,並且每一個當機修復都要附一個回歸測試。細節見
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work)。
有問題、想法,或是想先討論一個 bug?來 [Discord](https://discord.gg/artcraft)。
<br>
## Downloads
**第一次使用 LightCraft?** 從 [getartcraft.com 的 LightCraft 頁面](https://getartcraft.com/apps/lightcraft)下載。那是最簡單的安裝方式。
**想要特定的建置版本或格式?** 在 GitHub 上,[最新發布版](https://github.com/storytold/lightcraft/releases/latest)包含下面列出的所有建置版本,[所有發布版](https://github.com/storytold/lightcraft/releases)則有更早的版本與其說明。檔名中的 `<ver>` 是版本號,`SHA256SUMS.txt` 列出了每個檔案的檢查碼。
### Windows
| 建置版本 | 安裝程式 | 可攜版 |
|---|---|---|
| x64(64 位元 Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
| arm64(Snapdragon 及其他 ARM PC) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
| x86(32 位元) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
安裝程式與執行檔都有程式碼簽章。安裝程式會詢問安裝位置(預設為
`C:\Program Files\LightCraft`;升級時會留在你選擇的資料夾),結束時會有一頁確認 LightCraft 已安裝,
並可選擇立即啟動。無訊息安裝:`msiexec /i lightcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`。
### macOS
| 建置版本 | 檔案 | 備註 |
|---|---|---|
| 應用程式,通用版(Apple 晶片 + Intel) | `lightcraft-<ver>-macos-universal.dmg` | 已簽章並經過公證 |
| 命令列工具,通用版 | `lightcraft-cli-<ver>-macos-universal.zip` | 已簽章並經過公證 |
### Linux
| 格式 | x86_64 | aarch64(ARM64) | 備註 |
|---|---|---|---|
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | 隨處可執行;用 [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate)(`.zsync` 檔案)自我更新 |
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | 沙箱化;`flatpak install --user <file>` |
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
| 壓縮檔 | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | 解壓到任意位置即可使用 |
RISC-V(riscv64):`lightcraft-<ver>-linux-riscv64.tar.gz`,僅提供壓縮檔(glibc 2.39 或更新版本,例如 Ubuntu 24.04+)。
在 Wayland 桌面環境中,LightCraft 會在可用時透過 XWayland 執行,因為只有 X11 支援
視窗的檔案拖放;設定 `LIGHTCRAFT_WAYLAND=1` 可改用原生 Wayland(更清晰的小數縮放,但無法拖放)。
### FreeBSD
| 建置版本 | 檔案 |
|---|---|
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
### Web (WebAssembly)
| 建置版本 | 檔案 | 備註 |
|---|---|---|
| 靜態網站 | `lightcraft-web-<ver>.zip` | 可在現代瀏覽器中執行;可託管在任何靜態伺服器上 |
<br>
## The Crafting Apps
LightCraft 是 **Crafting Apps** 之一:來自 [ArtCraft](https://getartcraft.com/) 團隊的免費開源創作工具,
每一個都用 Rust 從零開始撰寫,每一個都能獨立使用。
| | 應用程式 | 用途 | 程式碼 | 了解更多 |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | 影像編輯:圖層、遮色片、文字和真正的 PSD 檔案 | [GitHub](https://github.com/storytold/photocraft) | [網站](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | 向量插畫 | [GitHub](https://github.com/storytold/vectorcraft) | [網站](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | 影片剪輯、調色與聲音 | [GitHub](https://github.com/storytold/filmcraft) | [網站](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **照片庫與 raw 顯影 · 你現在在這裡** | [GitHub](https://github.com/storytold/lightcraft) | [網站](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | 閱讀、整理與保護 PDF | [GitHub](https://github.com/storytold/pdfcraft) | [網站](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | 動態圖形與視覺特效 | [GitHub](https://github.com/storytold/effectcraft) | [網站](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | 版面編排與出版 | [GitHub](https://github.com/storytold/designcraft) | [網站](https://getartcraft.com/apps/designcraft) |
還有 [**ArtCraft**](https://getartcraft.com/) 本身,我們為想要真正掌控創作的人打造的 AI 影像與影片工作室。
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
我們的 Discord 是各種創作者聚集的地方:畫畫的人、拍照的人、做動畫的人、剪片子的人、
排版的人,還有仍在摸索自己喜歡做什麼的人。分享你在做的東西,
尋求幫助,告訴我們哪裡壞了,或者告訴我們你希望這些工具能做到什麼。
不管你用什麼媒材,也不管你做了多久,這裡都歡迎你。
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/lightcraft">LightCraft</a>
</p>
<br>
## License and credits
LightCraft 採用 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE) 雙重授權,任你選擇。
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors。必要的聲明見 [NOTICE](NOTICE)。
隨附的字型、圖示、影像與其他資產保留各自的開放授權;每一項都在
[assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 中列出了作者、來源與授權。
展示用照片皆為公眾領域作品,取自 Wikimedia Commons:安瑟·亞當斯,*《提頓山脈與蛇河》*
(1942,美國國家檔案館);桃樂絲·蘭格,*《移民母親》*(1936,美國國會圖書館);比爾·安德斯/NASA,
*《地出》*(1968);NASA,*《藍色大理石》*(1972)。示範照片庫由 LightCraft 程式產生。介面字型:
Inter(SIL OFL 1.1)。使用 [craft-fonts](https://github.com/storytold/craft-fonts) 建置的版本(所有正式發布版)
還會內嵌其中文與日文字型(Noto Sans CJK SC、BIZ UDPGothic、BIZ UDMincho、Shippori Mincho;SIL OFL 1.1),列於其
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) 中。所有圖示皆為原創。
[`docs/brand/`](docs/brand/) 中的 ArtCraft 名稱、字標與標誌是
ArtCraft 團隊的商標,不在本授權範圍內。它們只能以未經修改的形式,作為
本儲存庫與 LightCraft 的一部分,依 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。
分支與修改版本必須移除它們。
<sub>Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 與 InDesign 是 Adobe Inc. 在美國及/或其他國家的商標或註冊商標。LightCraft 是一個獨立的開源專案,與 Adobe Inc. 沒有任何關係,也未獲其贊助或認可;這些名稱僅用於說明它所相容的工作流程。</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>由 <a href="https://getartcraft.com/">ArtCraft</a> 團隊與社群製作。</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
照片庫、工具與預設集面板可以重新分組、拆分、浮動於應用程式視窗內、關閉與重新開啟。工具面板會保留目前的編輯/裁切/遮色片模式;照片檢視區與底片顯示窗格則維持在一起。拖曳面板標題到某個目標邊緣或另一個標題上即可調整。右鍵選單提供「浮動面板」、「移至群組」與「關閉面板」。既有的面板切換按鈕會在上次儲存的位置重新開啟面板;「重設面板配置」則會還原應用程式的版面配置。浮動面板會保持在目前的應用程式視窗之內。
+9 -6
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; German catalog 91% → 97%: interface strings and What's New lines translated) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -49,7 +49,7 @@ Hours are calibrated on this repo's PR history; see
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Localization | ~64% of the 12 key languages | 8 of 12 present | 130–210 h (+135–239 h new scripts) | [localization-parity](docs/localization-parity.md) |
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
@@ -78,7 +78,7 @@ Weights and evidence: [target-app-parity.md](docs/target-app-parity.md#features)
## Languages
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated); none is `full`
Measured from `crates/ui-egui/locales` (share of 2,304 catalog messages translated; German counts 3,644 of 3,754, because its What's New lines add keys); none is `full`
while lower-layer errors stay English. Detail: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Translated | Status |
@@ -90,13 +90,13 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
| Arabic | ar | 0% | none (no RTL / shaping) |
| French | fr | 88% | partial |
| Portuguese (Brazil) | pt-br | 85% | partial |
| Indonesian | id | 0% | none |
| Indonesian | id | 100% (What's New in English) | partial |
| Japanese | ja | 87% | partial |
| German | de | 91% | partial |
| German | de | 97% (3,644/3,754) | partial |
| Korean | ko | 0% | none (no Hangul face) |
| Vietnamese | vi | 0% | none |
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%), Finnish (79%).
## Upcoming
@@ -186,15 +186,18 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Indonesian interface (`id`): every plain and formats message, plus 69 command labels, locale, menu command and tests; What's New left in English; Localization ~55% → ~64% (mean of the 12 key languages), 8 of 12 present |
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-16 | minor | German 91% → 97% (217 interface strings and 862 What's New lines added); headline numbers unchanged |
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-17 | minor | Finnish (`fi`) interface language added: 2,610 of 3,308 messages (79%), locale, menu command and tests; headline numbers unchanged |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged |
+4
View File
@@ -33,7 +33,11 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
| `crates/ui-egui/locales/id.json`, `crates/ui-egui/locales/id-formats.json` | Indonesian interface catalogs | @RevDonz with Claude Code; PhotoCraft, VectorCraft, FilmCraft and FigCraft translation contributors for reused labels | original translations from English source; 356 labels match the sibling apps' Indonesian wording, reviewed from [PhotoCraft c39d752](https://github.com/storytold/photocraft/blob/c39d752758155492bbaba71d802cfaa03206fb4a/crates/ui-egui/src/i18n/id.tsv), [VectorCraft 9fe0f03](https://github.com/storytold/vectorcraft/blob/9fe0f03de2bd29e8c86a1179f977c3e651069424/crates/ui-egui/src/i18n/id.tsv), [FilmCraft fae7f8a](https://github.com/storytold/filmcraft/blob/fae7f8a9be09bd74f95fbe08c3d98adc17fe1030/crates/ui-egui/src/i18n/id.tsv) and [FigCraft 8482c3e](https://github.com/storytold/figcraft/blob/8482c3e85bc5f2a430e2f3be2826674872f6d084/crates/ui-egui/locales/id.toml) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-19 | adapted to LightCraft's photo context, count-neutral nouns, reviewed placeholders |
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
| `crates/ui-egui/locales/sv.json`, `crates/ui-egui/locales/sv-formats.json` | Swedish interface catalogs | LightCraft contributors | original work: clean-room Swedish translations of English source phrases | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-15 | none |
| `crates/ui-egui/locales/fi.json`, `crates/ui-egui/locales/fi-formats.json` | Finnish interface catalogs | @roopeg with Claude Code (AI-assisted; native-speaker review by @roopeg) | original work: translations of the English source phrases, using common Finnish photo-editing vocabulary; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-17 | count-neutral labels for Finnish partitive agreement, Finnish dates and times (`20.9.2026`, `16:04`), reviewed placeholders |
| `crates/ui-egui/locales/vi.json`, `crates/ui-egui/locales/vi-formats.json` | Vietnamese interface catalogs | @duydev (DUY Tran Nhat) with AI assistance (Cursor) | original work: translations of English source phrases; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-13 | plural-neutral counts, Vietnamese dates and weekdays, reviewed placeholders |
Notes
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
+5 -2
View File
@@ -139,8 +139,11 @@ pub fn specs() -> Vec<CommandSpec> {
.into_iter()
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
.collect();
// XMP: the sidecar if there is one, else the file's own packet
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
// Keep sidecar precedence; reuse the loaded bytes for embedded XMP.
let packet = match crate::sidecar::find_sidecar(path, s.sidecar_naming(ph.id)) {
Some(sc) => std::fs::read_to_string(sc).ok(),
None => lightcraft_meta::embedded(&bytes).xmp,
};
let xmp: Vec<Value> = packet
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
+1 -1
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Read Metadata from File",
["Photo"],
None,
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
"{ids?} — reads each photo's XMP sidecar, or embedded XMP in raw/DNG and JPEG files when no sidecar exists: metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on; JPEG uses the first complete standard XMP APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; an unreadable sidecar fails without embedded fallback → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
has_selection,
read
),
+28
View File
@@ -93,6 +93,17 @@ const NON_ADJUSTMENT: &[&str] = &[
// the file's as-shot white, recorded next to the edit (ours comes from the file itself)
"crs:AsShotTemperature",
"crs:AsShotTint",
// which Lightroom version may read the edit, and checksums of the profiles it names
"crs:CompatibleVersion",
"crs:CameraProfileDigest",
"crs:LensProfileDigest",
// the lens profile Lightroom picked: ours is the file's own, only `LensProfileEnable` carries over
"crs:LensProfileName",
"crs:LensProfileFilename",
"crs:LensProfileIsEmbedded",
"crs:LensProfileSetup",
// a crop tool option (keep the crop inside the photo), not the crop itself
"crs:CropConstrainToUnitSquare",
];
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
@@ -492,6 +503,8 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|| k == "AutoTone"
|| k == "AutoGrayscaleMix"
|| (k == "PointColors" && value_is(k, &empty_points))
// the lens profile's strength, written at its default (100 %) next to every profile
|| (matches!(k, "LensProfileDistortionScale" | "LensProfileVignettingScale") && value_is(k, &|v| v.iter().all(|s| s.trim() == "100")))
};
let mut unmapped: Vec<String> = props
.keys()
@@ -869,6 +882,21 @@ mod tests {
assert_eq!(got, ["FutureSlider", "PointColors"]);
}
#[test]
fn lightroom_profile_bookkeeping_is_not_reported() {
// what Lightroom Classic writes next to every raw's edit: none of it changes the rendering
let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D"
crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False"
crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100"
crs:CropConstrainToUnitSquare="1""#;
let look = r#"<crs:Look rdf:parseType="Resource"><crs:Name>Vivid</crs:Name></crs:Look>"#;
assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::<String>::new());
assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap");
// a lens profile's strength away from 100 % is an adjustment we don't carry over
let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, "");
assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]);
}
#[test]
fn disabled_lens_profile_switch_keeps_embedded_corrections() {
// Lightroom applies a file's built-in lens corrections regardless of its profile switch,
+25 -3
View File
@@ -82,7 +82,11 @@ pub enum ResizeMode {
Width,
/// `value` = height in pixels.
Height,
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the larger).
/// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤
/// `height` (Lightroom's "Width & Height").
WidthHeight,
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the
/// larger; Lightroom's "Dimensions").
Dimensions,
/// `value` = megapixels.
Megapixels,
@@ -96,7 +100,7 @@ pub enum ResizeMode {
pub struct Resize {
pub mode: ResizeMode,
pub value: f32,
/// Second dimension for [`ResizeMode::Dimensions`].
/// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`].
pub height: u32,
/// Never upscale past the photo's own (cropped) size.
pub dont_enlarge: bool,
@@ -127,6 +131,7 @@ impl Resize {
ResizeMode::ShortEdge => v / short,
ResizeMode::Width => v / w,
ResizeMode::Height => v / h,
ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h),
ResizeMode::Dimensions => {
let (a, b) = (v, self.height as f64);
(a.max(b) / long).min(a.min(b) / short)
@@ -721,6 +726,7 @@ pub const OPTION_PARAMS: &[&str] = &[
"height",
"megapixels",
"percent",
"fit",
"dontEnlarge",
"ppi",
"limitKb",
@@ -940,6 +946,7 @@ impl ExportOptions {
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
num(k, v, 0.0, 1.0e9)?;
}
"fit" => one_of(k, v, &["dimensions", "widthHeight"])?,
"resize" => {
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
@@ -1133,8 +1140,12 @@ impl ExportOptions {
return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r });
}
let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge };
// `fit: widthHeight` binds each edge to its own number; the default fits either orientation
let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight"));
Some(match (f("width"), f("height")) {
(Some(w), Some(h)) => Resize { height: h.min(65_535.0) as u32, ..r(ResizeMode::Dimensions, w) },
(Some(w), Some(h)) => {
Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) }
}
(Some(w), None) => r(ResizeMode::Width, w),
(None, Some(h)) => r(ResizeMode::Height, h),
(None, None) => {
@@ -2881,6 +2892,13 @@ mod tests {
assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667)));
assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000)));
assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500)));
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225)));
// Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620)
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180)));
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337)));
assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160)));
// `fit` only matters with both numbers
assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667)));
let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap();
assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}");
assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000)));
@@ -2895,6 +2913,8 @@ mod tests {
let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false };
let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize;
assert_eq!(back, Some(r));
let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true };
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r));
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
// never larger than the encoders' limit, never empty
@@ -2966,6 +2986,7 @@ mod tests {
mode(ResizeMode::Width, value, 0),
mode(ResizeMode::Height, value, 0),
mode(ResizeMode::Dimensions, value, 1365),
mode(ResizeMode::WidthHeight, value, 1365),
mode(ResizeMode::Megapixels, value / 1000.0, 0),
mode(ResizeMode::Percent, value / 100.0, 0),
] {
@@ -3262,6 +3283,7 @@ mod tests {
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight"));
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
+283 -5
View File
@@ -27,8 +27,10 @@
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
//! Manual reads also accept the first complete standard XMP APP1 in a bounded JPEG header (see `read_jpeg_xmp`).
use std::collections::{HashMap, HashSet};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -520,18 +522,77 @@ fn file_path(p: &Photo) -> Option<&str> {
}
}
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
/// The XMP packet that describes a photo on disk: its sidecar, else embedded raw/DNG or JPEG XMP.
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
if let Some(sc) = find_sidecar(original, naming)
&& let Ok(s) = std::fs::read_to_string(&sc)
{
return Some((s, sc));
if let Some(sc) = find_sidecar(original, naming) {
// A discovered sidecar keeps precedence even when reading it fails.
return std::fs::read_to_string(&sc).ok().map(|s| (s, sc));
}
if kind == MediaKind::Raw {
let bytes = std::fs::read(original).ok()?;
let x = lightcraft_meta::embedded(&bytes).xmp?;
return Some((x, PathBuf::from(original)));
}
if kind == MediaKind::Image {
let mut file = std::fs::File::open(original).ok()?;
let x = read_jpeg_xmp(&mut file, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS)?;
return Some((x, PathBuf::from(original)));
}
None
}
const JPEG_XMP_HEADER_SPAN: u64 = 16 * 1024 * 1024;
const JPEG_XMP_HEADER_STEPS: usize = 4096;
/// Read from SOI at the current position, stopping at the first standard XMP packet or SOS/EOI.
/// The span includes SOI, segment headers and skipped payloads; each marker/fill step after SOI
/// consumes one iteration. Only a complete APP1 payload (at most 65533 bytes) reaches the shared
/// parser, in a single SOI+APP1 wrapper (at most 65539 bytes). This does not validate the whole JPEG.
fn read_jpeg_xmp(reader: &mut (impl Read + Seek), header_span: u64, marker_steps: usize) -> Option<String> {
let mut header = reader.take(header_span);
let mut soi = [0; 2];
header.read_exact(&mut soi).ok()?;
if soi != [0xff, 0xd8] {
return None;
}
let mut filled = false;
for _ in 0..marker_steps {
if !filled {
let mut prefix = [0];
header.read_exact(&mut prefix).ok()?;
if prefix != [0xff] {
return None;
}
}
let mut code = [0];
header.read_exact(&mut code).ok()?;
let [marker] = code;
filled = marker == 0xff;
match marker {
0xff | 0xd8 | 0xd0..=0xd7 | 0x01 => continue,
0xd9 | 0xda => return None,
_ => {}
}
let mut length = [0; 2];
header.read_exact(&mut length).ok()?;
let payload_len = u16::from_be_bytes(length).checked_sub(2)?;
let remaining = header.limit().checked_sub(u64::from(payload_len))?;
if marker != 0xe1 {
// Seeking must spend the same span budget as reading, before the seek is attempted.
header.set_limit(remaining);
header.get_mut().seek(SeekFrom::Current(i64::from(payload_len))).ok()?;
continue;
}
let segment_len = 6usize.checked_add(usize::from(payload_len))?;
let [hi, lo] = length;
let mut segment = Vec::with_capacity(segment_len);
segment.extend_from_slice(&[0xff, 0xd8, 0xff, 0xe1, hi, lo]);
segment.resize(segment_len, 0);
header.read_exact(segment.get_mut(6..)?).ok()?;
if let Some(xmp) = lightcraft_meta::jpeg_segments(&segment).xmp {
return Some(xmp);
}
}
None
}
@@ -1019,3 +1080,220 @@ mod tests_repair_provenance;
#[cfg(test)]
#[path = "sidecar/tests_stem_owners.rs"]
mod tests_stem_owners;
#[cfg(test)]
mod jpeg_header_tests {
use super::{JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS, read_jpeg_xmp};
use std::io::{self, Cursor, Read, Seek, SeekFrom};
struct CountedReader {
cursor: Cursor<Vec<u8>>,
bytes_read: usize,
largest_read: usize,
seeks: usize,
chunk: usize,
read_error_at: Option<u64>,
seek_error: bool,
}
impl CountedReader {
fn new(bytes: Vec<u8>) -> Self {
Self { cursor: Cursor::new(bytes), bytes_read: 0, largest_read: 0, seeks: 0, chunk: usize::MAX, read_error_at: None, seek_error: false }
}
}
impl Read for CountedReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.largest_read = self.largest_read.max(buf.len());
if self.read_error_at.is_some_and(|at| self.cursor.position() >= at) {
return Err(io::Error::other("injected header read failure"));
}
let len = buf.len().min(self.chunk);
let read = self.cursor.read(&mut buf[..len])?;
self.bytes_read += read;
Ok(read)
}
}
impl Seek for CountedReader {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.seeks += 1;
if self.seek_error {
return Err(io::Error::other("injected header seek failure"));
}
self.cursor.seek(pos)
}
}
fn segment(marker: u8, payload: &[u8]) -> Vec<u8> {
let len = u16::try_from(payload.len() + 2).unwrap();
let mut bytes = vec![0xff, marker];
bytes.extend_from_slice(&len.to_be_bytes());
bytes.extend_from_slice(payload);
bytes
}
fn packet(rating: i8) -> String {
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), ..Default::default() }, None)
}
fn xmp_segment(packet: &str) -> Vec<u8> {
let mut payload = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
payload.extend_from_slice(packet.as_bytes());
segment(0xe1, &payload)
}
fn jpeg(packet: &str) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
bytes.extend(xmp_segment(packet));
bytes
}
#[test]
fn first_standard_packet_skips_other_segments_and_stops_before_tail() {
let first = packet(2);
let first_payload_len = xmp_segment(&first).len() - 4;
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[7; 17]));
bytes.extend(segment(0xe1, b"Exif\0\0tiny"));
let mut extended = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
extended.extend_from_slice(&[0; 40]);
extended.extend_from_slice(b"not a standard packet");
bytes.extend(segment(0xe1, &extended));
bytes.extend(xmp_segment(&first));
let end = bytes.len();
bytes.extend(xmp_segment(&packet(5)));
bytes.extend_from_slice(&[0xff, 0xda, 0, 2]);
bytes.extend_from_slice(&[3; 128]);
let mut reader = CountedReader::new(bytes);
reader.read_error_at = Some(end as u64);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), Some(first));
assert_eq!(reader.cursor.position(), end as u64, "later packets and image bytes are not read");
assert_eq!(reader.bytes_read, end - 17, "APP0 payload is skipped, not buffered");
assert_eq!(reader.seeks, 1);
assert_eq!(reader.largest_read, first_payload_len, "reads at most one complete APP1 payload at a time");
}
#[test]
fn stops_at_sos_or_eoi_without_reading_later_xmp() {
for stop in [0xda, 0xd9] {
let mut bytes = vec![0xff, 0xd8, 0xff, stop];
bytes.extend(xmp_segment(&packet(3)));
let mut reader = CountedReader::new(bytes);
reader.read_error_at = Some(4);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (4, 4, 0));
}
}
#[test]
fn rejects_incomplete_app1_even_when_partial_xmp_parses() {
let expected = packet(4);
let mut bytes = jpeg(&expected);
let declared = u16::from_be_bytes([bytes[4], bytes[5]]) + 7;
bytes[4..6].copy_from_slice(&declared.to_be_bytes());
let partial = lightcraft_meta::jpeg_segments(&bytes).xmp.unwrap();
assert_eq!(partial, expected, "the shared extractor tolerates a truncated declared payload");
assert_eq!(super::parse_sidecar(&partial, crate::crs::Target::Rendered).unwrap().rating, Some(4));
let len = bytes.len();
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read, len);
}
#[test]
fn span_budget_counts_soi_headers_and_skipped_payloads() {
let expected = packet(3);
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[9; 23]));
bytes.extend(xmp_segment(&expected));
let end = bytes.len();
let mut exact = CountedReader::new(bytes.clone());
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
assert_eq!(exact.cursor.position(), end as u64, "a complete early APP1 does not require EOI");
assert_eq!(exact.bytes_read, end - 23);
let mut short = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut short, (end - 1) as u64, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((short.bytes_read, short.cursor.position(), short.seeks), (10, 33, 1), "reject before APP1 payload read");
assert_eq!(short.largest_read, 2);
}
#[test]
fn span_budget_rejects_skip_before_seeking() {
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[8; 18]));
bytes.extend(xmp_segment(&packet(1)));
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, 6, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (6, 6, 0));
}
#[test]
fn marker_budget_counts_fill_and_standalone_markers() {
let expected = packet(1);
// Two fill steps, SOI, TEM, RST3, then APP1: six steps after the initial SOI.
let mut bytes = vec![0xff, 0xd8, 0xff, 0xff, 0xff, 0xd8, 0xff, 0x01, 0xff, 0xd3];
bytes.extend(xmp_segment(&expected));
for (steps, end) in [(0, 2), (5, 10)] {
let mut reader = CountedReader::new(bytes.clone());
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, steps), None);
assert_eq!((reader.bytes_read, reader.cursor.position()), (end, end as u64));
}
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, 6), Some(expected));
}
#[test]
fn rejects_invalid_headers_and_segment_lengths() {
for bytes in [
vec![],
vec![0xff],
b"\x89PNG\r\n\x1a\n".to_vec(),
vec![0xff, 0xd8, 0, 0xe1, 0, 2],
vec![0xff, 0xd8, 0xff, 0xe1, 0],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 0],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 1],
vec![0xff, 0xd8, 0xff, 0xe1, 0, 3],
] {
let len = bytes.len();
let mut reader = CountedReader::new(bytes);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert!(reader.bytes_read <= len);
}
}
#[test]
fn propagates_read_and_seek_failures() {
for at in [0, 6] {
let mut reader = CountedReader::new(jpeg(&packet(4)));
reader.read_error_at = Some(at);
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read as u64, at);
}
let mut bytes = vec![0xff, 0xd8];
bytes.extend(segment(0xe0, &[1]));
bytes.extend(xmp_segment(&packet(4)));
let mut reader = CountedReader::new(bytes);
reader.seek_error = true;
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
assert_eq!((reader.bytes_read, reader.seeks), (6, 1));
}
#[test]
fn handles_short_reads_without_bypassing_span_limit() {
let expected = packet(2);
let bytes = jpeg(&expected);
let end = bytes.len();
let mut exact = CountedReader::new(bytes.clone());
exact.chunk = 1;
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
assert_eq!(exact.bytes_read, end);
for (span, read) in [(0, 0), (1, 1), (end - 1, 6)] {
let mut reader = CountedReader::new(bytes.clone());
reader.chunk = 1;
assert_eq!(read_jpeg_xmp(&mut reader, span as u64, JPEG_XMP_HEADER_STEPS), None);
assert_eq!(reader.bytes_read, read);
assert!(reader.cursor.position() <= span as u64);
}
}
}
+208
View File
@@ -621,6 +621,214 @@ fn dng_embedded_crs_settings_are_read_on_import() {
let _ = std::fs::remove_dir_all(&src);
}
fn jpeg_xmp_packet(rating: i8, title: &str) -> String {
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), title: Some(title.into()), ..Default::default() }, None)
}
fn write_jpeg_xmp(path: &Path, packet: &str) -> Vec<u8> {
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]);
let meta = lightcraft_codecs::EncodeMeta { xmp: Some(packet), ..Default::default() };
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
assert_eq!(lightcraft_meta::embedded(&bytes).xmp.as_deref(), Some(packet), "the procedural JPEG carries the requested packet");
std::fs::write(path, &bytes).unwrap();
bytes
}
#[test]
fn embedded_jpeg_xmp_reread_refreshes_metadata_and_undoes_once() {
let src = temp_dir("jpeg-xmp-refresh");
let file = src.join("photo.jpg");
let initial = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Initial embedded title"));
let mut s = Session::new().with_fs();
let imported = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
assert_eq!(imported["imported"].as_array().unwrap().len(), 1, "{imported}");
let id = only(&s);
assert_eq!(s.catalog.photo(id).unwrap().kind, lightcraft_catalog::MediaKind::Image);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
assert!(!s.xmp.auto_write);
// Change the packet after import: rereading must use the file, not the imported metadata.
let changed = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Changed embedded title"));
assert_ne!(initial, changed);
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([{"id": id.0, "from": file.to_string_lossy()}]), "embedded JPEG XMP must be reread: {read}");
assert_eq!(read["failed"], json!([]), "{read}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (4, "Changed embedded title"));
assert_eq!(photo.develop, before.develop);
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title), "one undo restores both fields");
assert_eq!(std::fs::read(&file).unwrap(), changed, "reading and undo never rewrite the original");
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_keeps_sidecar_precedence() {
let src = temp_dir("jpeg-xmp-sidecar");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
let sidecar = src.join("photo.xmp");
let packet = jpeg_xmp_packet(5, "Sidecar title");
std::fs::write(&sidecar, &packet).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([{"id": id.0, "from": sidecar.to_string_lossy()}]), "{read}");
assert_eq!(read["failed"], json!([]), "{read}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
// A malformed sidecar must report its failure, not fall back to the valid embedded packet.
let malformed = "<a>".repeat(1000);
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
std::fs::write(&sidecar, &malformed).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "{read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), malformed);
assert_eq!(std::fs::read(&file).unwrap(), original);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_malformed_input_preserves_state_and_undo() {
let src = temp_dir("jpeg-xmp-malformed");
let file = src.join("photo.jpg");
write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Original title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
let malformed = "<a>".repeat(1000);
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
let broken_packet = write_jpeg_xmp(&file, &malformed);
let truncated_container = vec![0xff, 0xd8, 0xff, 0xe1, 0, 40, b'x'];
for bytes in [broken_packet, truncated_container] {
std::fs::write(&file, &bytes).unwrap();
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "{read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), bytes);
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
}
// Failed reads do not add an undo entry: the last successful title edit is still next.
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title));
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_non_utf8_sidecar_preserves_state_and_undo() {
let src = temp_dir("jpeg-xmp-non-utf8-sidecar");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
assert!(!s.xmp.auto_write);
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
let before = s.catalog.photo(id).unwrap().clone();
let sidecar = src.join("photo.xmp");
let invalid = [0xff, 0xfe, 0x80];
std::fs::write(&sidecar, invalid).unwrap();
assert_eq!(std::fs::read_to_string(&sidecar).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
assert_eq!(crate::sidecar::find_sidecar(file.to_str().unwrap(), s.sidecar_naming(id)), Some(sidecar.clone()));
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
assert_eq!(read["read"], json!([]), "an existing non-UTF-8 sidecar must not fall back to embedded JPEG XMP: {read}");
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
assert_eq!(read["failed"][0]["id"], id.0);
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
s.execute("edit.undo", &json!({})).unwrap();
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title), "failed reads must not add an undo entry");
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), original);
assert_eq!(std::fs::read(&sidecar).unwrap(), invalid);
assert!(!src.join("photo.jpg.xmp").exists());
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn embedded_jpeg_xmp_reread_all_metadata_uses_supplied_bytes() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let src = temp_dir("jpeg-xmp-all-metadata");
let file = src.join("photo.jpg");
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "On-disk title"));
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
let id = only(&s);
assert!(!s.xmp.auto_write);
let before = s.catalog.photo(id).unwrap().clone();
let supplied = write_jpeg_xmp(&src.join("supplied.jpg"), &jpeg_xmp_packet(4, "Supplied title"));
let reads = Arc::new(AtomicUsize::new(0));
let counted = reads.clone();
let expected_path = file.to_string_lossy().into_owned();
s.media.file_bytes = Some(Arc::new(move |path| {
assert_eq!(path, expected_path);
counted.fetch_add(1, Ordering::SeqCst);
Ok(supplied.clone())
}));
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), 1);
let xmp = query["xmp"].as_array().unwrap();
assert!(
xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Supplied title"),
"All Metadata must use the bytes already supplied: {query}"
);
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "4"), "{query}");
assert!(!xmp.iter().any(|x| x["value"] == "On-disk title"), "{query}");
let sidecar = src.join("photo.xmp");
let packet = jpeg_xmp_packet(5, "Sidecar title");
std::fs::write(&sidecar, &packet).unwrap();
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
assert_eq!(reads.load(Ordering::SeqCst), 2);
let xmp = query["xmp"].as_array().unwrap();
assert!(xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Sidecar title"), "{query}");
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "5"), "{query}");
assert!(!xmp.iter().any(|x| x["value"] == "Supplied title"), "{query}");
let photo = s.catalog.photo(id).unwrap();
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
assert_eq!(photo.develop, before.develop);
assert_eq!(std::fs::read(&file).unwrap(), original);
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn demo_photos_have_no_sidecar() {
let mut s = Session::with_demo();
+1 -1
View File
@@ -132,7 +132,7 @@ impl Backend for Headless {
"engine.commands" => {
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
"enabled": self.session.active().is_some()}));
Ok(Value::Array(v))
}
+3 -1
View File
@@ -243,8 +243,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation)"},
"width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation, unless `fit` says otherwise)"},
"height": {"type": "integer", "description": "Output height in px"},
"fit": {"type": "string", "enum": ["dimensions", "widthHeight"], "description": "With width + height: dimensions (default) fits either orientation (the larger number goes to the long edge); widthHeight limits the width to `width` and the height to `height` as the photo stands"},
"megapixels": {"type": "number", "description": "Output size in megapixels"},
"percent": {"type": "number", "description": "Output size as a percentage of full size"},
"dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"},
@@ -730,6 +731,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"height",
"megapixels",
"percent",
"fit",
"dontEnlarge",
"ppi",
"quality",
+39 -5
View File
@@ -127,20 +127,32 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata {
m
}
/// Degrees/minutes/seconds triplet as decimal degrees. A component that is present but not a number (a rational
/// with a zero denominator, as in the placeholder GPS block phones write without a fix) makes the whole value unusable.
fn dms(ifd: &Ifd, tag: u16) -> Option<f64> {
let v = ifd.f64s(tag)?;
let d = *v.first()?;
let deg = d + v.get(1).copied().unwrap_or(0.0) / 60.0 + v.get(2).copied().unwrap_or(0.0) / 3600.0;
let v = ifd.value(tag)?;
let part = |i: usize| if i < v.count() { v.get_f64(i) } else { Some(0.0) };
let d = v.get_f64(0)?;
let deg = d + part(1)? / 60.0 + part(2)? / 3600.0;
deg.is_finite().then_some(deg)
}
/// `Some(true)` for the negative hemisphere (`neg`), `Some(false)` for `pos`, `None` when the ref is missing or invalid.
fn hemisphere(g: &Ifd, tag: u16, pos: char, neg: char) -> Option<bool> {
match g.string(tag)?.trim_start().chars().next()?.to_ascii_uppercase() {
c if c == neg => Some(true),
c if c == pos => Some(false),
_ => None,
}
}
fn read_gps(g: &Ifd) -> Option<Gps> {
let mut lat = dms(g, t::GPS_LATITUDE)?;
let mut lon = dms(g, t::GPS_LONGITUDE)?;
if g.string(t::GPS_LATITUDE_REF).is_some_and(|s| s.starts_with('S')) {
if hemisphere(g, t::GPS_LATITUDE_REF, 'N', 'S')? {
lat = -lat;
}
if g.string(t::GPS_LONGITUDE_REF).is_some_and(|s| s.starts_with('W')) {
if hemisphere(g, t::GPS_LONGITUDE_REF, 'E', 'W')? {
lon = -lon;
}
if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
@@ -382,6 +394,28 @@ pub(crate) mod tests {
}
}
#[test]
fn placeholder_gps_block_is_no_location() {
// Android phones without a fix write NUL refs and 0/0 rationals (#1617).
let read = |lat_ref: &str, lat: Vec<(u32, u32)>| {
let mut gps = IfdBuilder::new();
gps.set(t::GPS_LATITUDE_REF, Value::Ascii(lat_ref.into()));
gps.set(t::GPS_LATITUDE, Value::Rational(lat));
gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into()));
gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)]));
let mut ifd0 = IfdBuilder::new();
ifd0.set_child(t::GPS_IFD, gps);
read_exif(&TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()).gps
};
let valid = vec![(33, 1), (51, 1), (3564, 100)];
assert!(read("N", valid.clone()).is_some());
assert_eq!(read("N", vec![(0, 0), (0, 0), (0, 0)]), None, "0/0 rationals");
assert_eq!(read("N", vec![(33, 1), (0, 0), (0, 0)]), None, "0/0 minutes");
assert_eq!(read("", valid.clone()), None, "NUL ref");
assert_eq!(read("", valid.clone()), None, "empty ref");
assert_eq!(read("X", valid), None, "invalid ref");
}
#[test]
fn exif_prefix_and_garbage() {
let mut b = b"Exif\0\0".to_vec();
+13 -1
View File
@@ -147,7 +147,7 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> {
match name.as_slice() {
b"ele" if parent == Some(b"trkpt") => {
if let Some(p) = pt.as_mut() {
p.ele = text.trim().parse().ok();
p.ele = text.trim().parse().ok().filter(|v: &f64| v.is_finite());
}
}
b"time" if parent == Some(b"trkpt") => {
@@ -351,6 +351,18 @@ mod tests {
assert_eq!(log.points[1].time - log.points[0].time, 60.0);
}
#[test]
fn non_finite_elevation_is_dropped() {
for ele in ["inf", "-inf", "NaN", "infinity"] {
let s = format!(
r#"<gpx><trk><trkseg><trkpt lat="45" lon="7"><ele>{ele}</ele><time>2026-01-01T00:00:00Z</time></trkpt></trkseg></trk></gpx>"#
);
let log = parse_gpx(&s).unwrap();
assert_eq!(log.points.len(), 1, "{ele}");
assert_eq!(log.points[0].elevation, None, "{ele}");
}
}
#[test]
fn interpolation_across_the_antimeridian() {
let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 };
+18 -1
View File
@@ -145,7 +145,14 @@ fn rational(n: f64, d: f64) -> String {
return "—".into();
}
let v = n / d;
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round()) } else { format!("{:.4}", v).trim_end_matches('0').to_string() }
if (v - v.round()).abs() < 1e-9 {
let r = v.round();
if r == 0.0 { "0".to_string() } else { format!("{}", r) }
} else {
let s = format!("{:.4}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
}
}
fn list<T: ToString>(v: &[T]) -> String {
@@ -370,6 +377,16 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> {
mod tests {
use super::*;
#[test]
fn rational_has_no_trailing_dot() {
assert_eq!(rational(1.0, 100000.0), "0");
assert_eq!(rational(-1.0, 100000.0), "0");
assert_eq!(rational(-1.0, 1e10), "0");
assert_eq!(rational(1000001.0, 100000.0), "10");
assert_eq!(rational(1.0, 3.0), "0.3333");
assert_eq!(rational(5.0, 2.0), "2.5");
}
#[test]
fn rows_from_written_exif() {
let m = crate::Metadata {
+8 -2
View File
@@ -457,8 +457,12 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option<Regi
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
let a = v.abs();
let d = a.trunc();
let m = (a - d) * 60.0;
let mut d = a.trunc();
let mut m = ((a - d) * 60.0 * 1e6).round() / 1e6;
if m >= 60.0 {
d += 1.0;
m = 0.0;
}
format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos })
}
@@ -866,6 +870,8 @@ mod tests {
assert_eq!(parse_gps_coord(""), None);
assert_eq!(parse_gps_coord("é"), None);
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N");
assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W");
}
/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°.
+2 -1
View File
@@ -68,6 +68,7 @@
"Adding": "Přidávání",
"Adding Lightroom photos…": "Přidávání fotografií z Lightroom…",
"Adding to Apple Photos": "Přidávání do Apple Photos",
"Dimensions": "Rozměry",
"No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nebyl nalezen grafický procesor: LightCraft vykresluje na CPU, což je mnohem pomalejší. Podrobnosti najdete v nabídce Nápověda → Informace o systému.",
"On NixOS, start it from `nix develop`.": "V NixOS ho spusťte z `nix develop`.",
"Files with the same content as another file in this import: only the first is imported.": "Soubory se stejným obsahem jako jiný soubor v tomto importu: importuje se jen první z nich.",
@@ -2047,7 +2048,7 @@
"Width": "Šířka",
"Width (% of photo)": "Šířka (% fotografie)",
"Width (px)": "Šířka (px)",
"Width × Height": "Šířka × výška",
"Width & Height": "Šířka & výška",
"Will Export": "Bude exportováno",
"Will be replaced": "Bude nahrazeno",
"Will be restored": "Bude obnoveno",
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -420,6 +420,7 @@
"Detect Faces": "Detectar caras",
"Detect Faces (Photo menu) needs it: a small one-time download.": "Detectar caras (menú Foto) lo necesita: una pequeña descarga única.",
"Diagonal": "Diagonal",
"Dimensions": "Dimensiones",
"Discard Preview Cache": "Descartar caché de vistas previas",
"Display P3": "Display P3",
"Display name": "Nombre visible",
@@ -1750,7 +1751,7 @@
"Width": "Ancho",
"Width (% of photo)": "Ancho (% de la foto)",
"Width (px)": "Ancho (px)",
"Width × Height": "Ancho × alto",
"Width & Height": "Ancho & alto",
"Will be replaced": "Se reemplazará",
"Will be restored": "Se restaurará",
"Window": "Ventana",
+300
View File
@@ -0,0 +1,300 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} usealla samannäköisellä tiedostolla: käytä Paikanna)",
" ({} in Recently Deleted)": " ({} Äskettäin poistetuissa)",
" in {g} group{}": " · ryhmiä: {g}{:.0}",
" · kept, not applied: {}": " · säilytetty, ei käytetty: {}",
" · {:.0}% clipped": " · {:.0}% leikkautunut",
" · {failed} couldn't be rendered": " · renderöinti epäonnistui: {failed}",
" · {n} skipped (could not be rendered)": " · {n} ohitettu (renderöinti ei onnistunut)",
" · {queued} waiting": " · odottaa: {queued}",
" · {skipped} skipped (file exists)": " · {skipped} ohitettu (tiedosto on olemassa)",
" · {} duplicate{} skipped": " · ohitettuja kaksoiskappaleita: {}{:.0}",
" · {} failed: {}": " · {} epäonnistui: {}",
" · {} moved": " · {} siirretty",
" · {} not readable": " · {} ei luettavissa",
" · {} original{} left at the source": " · alkuperäisiä jäi lähteeseen: {}{:.0}",
" · {} restored from Recently Deleted": " · {} palautettu Äskettäin poistetuista",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One ei tuo luetteloa, jonka avainsanassa on ; , < tai >: {names}",
" — kept but not applied: {}": " — säilytetty mutta ei käytetty: {}",
" — left out, as a keyword list can't hold them: {names}": " — jätetty pois, koska avainsanaluettelo ei voi sisältää niitä: {names}",
" — not carried over: {}{}": " — ei siirretty: {}{}",
"+{n} more": "+{n} lisää",
"+{} more": "+{} lisää",
", {failed} file{} not readable": ", ei luettavissa olevia tiedostoja: {failed}{:.0}",
", {} label": ", {} merkintä",
", {} star{}": ", tähtiä {}{:.0}",
"1 task is still running: {names}. Quitting stops it.": "1 tehtävä on yhä käynnissä: {names}. Lopettaminen pysäyttää sen.",
"; {kept} already had a location": "; jo sijaintitiedolliset: {kept}",
"; {n} gained synonyms": "; {n} sai synonyymejä",
"; {outside} outside its time range": "; {outside} aika-alueen ulkopuolella",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR:t {}, commitit {}, rivit +{} / −{} (Δ {}), binääriresurssit +{} / −{}, {} – {}",
"Add {local_n} to My Photos": "Lisää {local_n} Omiin kuviin",
"Add {} Photo{}": "Lisää kuvia: {}{:.0}",
"Add {} Photo{}, restore {}": "Lisää kuvia: {}{:.0}, palauta {}",
"Add “{}” to Favorites": "Lisää ”{}” suosikkeihin",
"Added {n} keyword{}": "Lisättyjä avainsanoja: {n}{:.0}",
"Added {n} photo{} to My Photos": "Omiin kuviin lisättyjä kuvia: {n}{:.0}",
"Added {n} photo{} to “{}”": "Lisättyjä kuvia kohteeseen ”{1}”: {n}{0:.0}",
"Added {what} to {name}": "{what} lisätty kohteeseen {name}",
"Added “{keyword}” to {n} photo{}": "Avainsana ”{keyword}” lisätty kuviin: {n}{:.0}",
"Adding photos… {} of {}": "Lisätään kuvia… {} / {}",
"All {n} selected photos move by the same amount.": "Kaikkia valittuja kuvia ({n}) siirretään saman verran.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Jo kirjastossa, Äskettäin poistetuissa: valittuja kuvia {}{:.0} · napsauta hiiren kakkospainikkeella → Palauta tai Poista pysyvästi tuodaksesi uudelleen",
"Already in the library: {} photo{} selected in All Photos": "Jo kirjastossa (Kaikki kuvat): valittuja kuvia {}{:.0}",
"Already in “{}”": "Jo kohteessa ”{}”",
"Applied {name}": "{name} käytetty",
"Apply to “{mask}” on Selected Photos": "Käytä maskiin ”{mask}” valituissa kuvissa",
"Auto ({})": "Auto ({})",
"Auto Import: added {} photo{}": "Automaattinen tuonti: lisättyjä kuvia {}{:.0}",
"Auto Sync: edits apply to {n} photos": "Automaattinen synkronointi: muokkaukset koskevat kuvia: {n}",
"Automatic ({} MB)": "Automaattinen ({} Mt)",
"Build 1:1 Previews ({scope})": "Luo 1:1-esikatselut ({scope})",
"Build Smart Previews ({scope})": "Luo älyesikatselut ({scope})",
"Build Standard-Sized Previews ({scope})": "Luo vakiokokoiset esikatselut ({scope})",
"Building previews for {} photos…": "Luodaan esikatseluja {} kuvalle…",
"Click {}:1": "Napsautus {}:1",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Väriavaruus: Rec. 2020 {curve} (HDR AVIF)",
"Couldn't open the editor: {e}": "Muokkausohjelmaa ei voitu avata: {e}",
"Create Keyword Tag Inside “{name}”…": "Luo avainsanatunniste kohteen ”{name}” sisään…",
"Create the keyword “{name}”": "Luo avainsana ”{name}”",
"Create {n} Virtual Copies": "Luo {n} virtuaalikopiota",
"Creates {} stacks from {} of {scope}": "Luo pinoja: {} · kuvia: {} ({scope})",
"Decoder: {reason}": "Purkaja: {reason}",
"Delete \"{label}\"": "Poista ”{label}”",
"Delete \"{label}\" (and the mask)": "Poista ”{label}” (ja maski)",
"Delete the effect preset “{name}”?": "Poistetaanko tehosteesiasetus ”{name}”?",
"Delete the keyword “{name}”?": "Poistetaanko avainsana ”{name}”?",
"Delete these {n} effect presets?": "Poistetaanko nämä {n} tehosteesiasetusta?",
"Delete {n} Photos": "Poista {n} kuvaa",
"Delete {n} effect preset{}? This can't be undone.": "Poistetaanko tehosteesiasetuksia: {n}{:.0}? Tätä ei voi kumota.",
"Delete “{current}”": "Poista ”{current}”",
"Delete “{name}”?": "Poistetaanko ”{name}”?",
"Denoising {name}: {done} of {total} tiles{}": "Kohinanpoisto {name}: {done} / {total} ruutua{}",
"Discard Smart Previews ({scope})": "Hylkää älyesikatselut ({scope})",
"Done ({what}): {done} photo{plural}": "Valmis ({what}): kuvia {done}{plural:.0}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Latautuu osoitteesta {host}. Kun se on saapunut ja tarkistettu, LightCraft asentaa sen, {after}.",
"Duplicate \"{label}\"": "Monista ”{label}”",
"Effect: {name}": "Tehoste: {name}",
"Example: {example}": "Esimerkki: {example}",
"Exported to {path}": "Viety kohteeseen {path}",
"Exported {n} keyword{}": "Viety avainsanoja: {n}{:.0}",
"Exported {n} photo{}": "Viety kuvia: {n}{:.0}",
"Exported {ok} of {total} photo{}": "Viety kuvia: {ok} / {total}{:.0}",
"Exported {} effect preset{}": "Viety tehosteesiasetuksia: {}{:.0}",
"Exported {} point curve preset{}": "Viety pistekäyrän esiasetuksia: {}{:.0}",
"Exported {} preset{}": "Viety esiasetuksia: {}{:.0}",
"Exporting {} of {total}": "Viedään {} / {total}",
"Filter bar — {active} active filter{}": "Suodatinpalkki — aktiivisia suodattimia: {active}{:.0}",
"Filtered: {label}": "Suodatettu: {label}",
"Focus {:.0}": "Tarkennus {:.0}",
"Folder “{}” (and its subfolders)": "Kansio ”{}” (ja sen alikansiot)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Löydettyjä puuttuvia kuvia: {n}{:.0}; edelleen puuttuu: {left}{unsure}",
"Healed {n} dust spot{}": "Korjattuja pölyhiukkasia: {n}{:.0}",
"Import cancelled · {} photo{} added": "Tuonti peruttu · lisättyjä kuvia: {}{:.0}",
"Import failed: {e}": "Tuonti epäonnistui: {e}",
"Import warnings ({n}):\n{warnings}": "Tuontivaroitukset ({n}):\n{warnings}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Tuotuja maskitehosteita: {e}{:.0} (Maskaus → Tehoste)",
"Imported {name}": "{name} tuotu",
"Imported {n} point curve preset{}": "Tuotuja pistekäyrän esiasetuksia: {n}{:.0}",
"Imported {n} preset{}": "Tuotuja esiasetuksia: {n}{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Tuotuja esiasetuksia: {n}{:.0}, ei luettavissa olevia tiedostoja: {f}{:.0}",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Tuotuja profiileja: {profiles}{:.0} (Profiiliselain → niiden ryhmät)",
"Imported {} photo{}": "Tuotuja kuvia: {}{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Jonossa tekoälykohinanpoistoon ({ahead} edellä)…",
"Insert at the cursor (also written {})": "Lisää kohdistimen kohtaan (kirjoitetaan myös {})",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se poistetaan {count} kuvalta{:.0}, alla olevat avainsanat mukaan lukien. Kumoa palauttaa sen.",
"Keywords of {n} selected photos": "Valittujen kuvien ({n}) avainsanat",
"Label Set: {name}": "Merkintäjoukko: {name}",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ei vielä osaa purkaa tätä raw-muunnelmaa ({}). Muokkaat kameran upotettua JPEG-esikatselua, joka sisältää jo kameran kuvatyylin (esim. Monochrome) ja valkotasapainon.",
"LightCraft could not open its window: {e}": "LightCraft ei voinut avata ikkunaansa: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft ei voinut avata ikkunaansa: {e}\n\nLokitiedostossa on lisätietoja: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft pysähtyi odottamatta: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft pysähtyi odottamatta: {e}\n\nLokitiedostossa on lisätietoja: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-katalogi tarkastettu: kuvia {photos}, kokoelmia {collections}, puuttuvia {missing}",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; Ohjatun Uprightin tallennettua muunnosta ei tueta.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; tallennettua Upright-muunnosta ei tueta.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; tallennettu muunnos ohitetaan, koska Upright on pois päältä.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroomin geometriaa ei säilytetty kohteelle {path}; {mode} lasketaan uudelleen.",
"Lightroom import complete: {imported} photos added": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}, epäonnistuneita {failed}",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-tuonti valmis: lisättyjä kuvia {imported}; hakemistovaroitus: {warning}",
"Lightroom import failed: {e}{warning}": "Lightroom-tuonti epäonnistui: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Lightroom-tarkastelu epäonnistui: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Fontit: {} (OFL). Kuvakkeet: omat.",
"Making the denoised picture… {}%": "Tehdään kohinanpoistettua kuvaa… {}%",
"Matches: {}": "Täsmäävät: {}",
"Merging… {stage} {:.0}%": "Yhdistetään… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "Siirrä kuvia kohteeseen ”{1}”: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Siirretäänkö kuvat kohteeseen ”{1}” ({n}){0:.0}?",
"Move {what} to Recently Deleted?": "Siirretäänkö {what} Äskettäin poistettuihin?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Siirretty kuvia kohteeseen ”{1}”: {n}{0:.0}. Muokkaa → Kumoa siirtää ne takaisin.",
"Moved {} photo(s)": "Siirretty kuvia: {}",
"Moved; {} photo(s) relinked": "Siirretty; yhdistetty uudelleen kuvia: {}",
"Named ({named_n})": "Nimetyt ({named_n})",
"New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”",
"On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa",
"Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään",
"Preset: {name}": "Esiasetus: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Vain esikatselu — muokataan kameran upotettua JPEG-kuvaa ({})",
"Preview only — {}": "Vain esikatselu — {}",
"Previews ready for {done} photo{plural}": "Esikatselut valmiina {done} kuvalle{plural:.0}",
"Profile: {}": "Profiili: {}",
"Proof Copy ({})": "Vedoskopio ({})",
"Proof Preview · {}": "Vedoksen esikatselu · {}",
"Put inside “{parent}”": "Laita kohteen ”{parent}” sisään",
"Quick Develop · {n} photos": "Pikakehitys · kuvia: {n}",
"Quick Develop: {name}": "Pikakehitys: {name}",
"Rated {}": "Luokitus {}",
"Reading Lightroom catalog… {done} of {total}": "Luetaan Lightroom-katalogia… {done} / {total}",
"Reading photos… {done} of {total}": "Luetaan kuvia… {done} / {total}",
"Reading photos… {} of {}": "Luetaan kuvia… {} / {}",
"Redo {}": "Tee uudelleen {}",
"Remove “{name}”": "Poista ”{name}”",
"Remove “{name}” and its {count} photo{} from the library?": "Poistetaanko ”{name}” ja sen kuvat ({count}{:.0}) kirjastosta?",
"Remove “{}” from Favorites": "Poista ”{}” suosikeista",
"Removed from {}": "Poistettu kohteesta {}",
"Removed {what} from {name}": "{what} poistettu kohteesta {name}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "”{name}” poistettu {n} kuvalta. Muokkaa → Kumoa palauttaa sen.",
"Rename {n} Photos…": "Nimeä uudelleen {n} kuvaa…",
"Rename “{name}”": "Nimeä uudelleen ”{name}”",
"Renames {n} face{} across the library. The files are not changed.": "Nimeää uudelleen kasvoja koko kirjastossa: {n}{:.0}. Tiedostoja ei muuteta.",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Nimeää ”{from}” uudelleen {n} kuvassa{:.0} (myös sen alla olevat avainsanat). Käytä |-merkkiä tasojen erottamiseen, esim. Matka|Italia. Olemassa oleva nimi yhdistää nämä kaksi.",
"Rendering on the CPU: {why}": "Renderöidään suorittimella: {why}",
"Replace {} with:": "Korvaa {} seuraavalla:",
"Reset {name}": "Nollaa {name}",
"Reset {} Channel": "Nollaa kanava {}",
"Restore {}": "Palauta {}",
"Restore {} Photo{}": "Palauta kuvia: {}{:.0}",
"Restored {} photo{} from Recently Deleted": "Äskettäin poistetuista palautettu kuvia: {}{:.0}",
"Running on the processor: {}.": "Käytössä suoritin: {}.",
"Running on {}.": "Käytössä: {}.",
"Saved export preset “{}”": "Vientiesiasetus ”{}” tallennettu",
"Saved template {name}": "Mallipohja {name} tallennettu",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Pisteyttää {scope} tarkennuksen ja valotuksen mukaan ja etsii samankaltaiset otokset, jotka on otettu sekuntien sisällä toisistaan (sarjat).",
"Show {hidden} hidden location{}": "Näytä piilotettuja sijainteja: {hidden}{:.0}",
"Showing {n} of {}": "Näytetään {n} / {}",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Näyttää mallin ehdot, lataa sitten {} osoitteesta {host}, asentaa sen ja ottaa sen käyttöön. Mitään muuta ei lähetetä.",
"Skipped {problem}": "Ohitettu {problem}",
"Slideshow exported: {what}": "Diaesitys viety: {what}",
"Smart album: {}": "Älyalbumi: {}",
"Tagged {n} photo{} from the tracklog": "Reittilokista merkittyjä kuvia: {n}{:.0}",
"Template preview: {name}": "Mallipohjan esikatselu: {name}",
"The SAM 3 download failed: {e}": "SAM 3:n lataus epäonnistui: {e}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Kuvien XMP-sivutiedostoja ei voitu kirjoittaa ({n}; onko niiden kansio offline-tilassa?). LightCraft yrittää edelleen.",
"There is a keyword set “{name}” already": "Avainsanajoukko ”{name}” on jo olemassa",
"There is a “{name}” at the top level already. Merge the two?": "Ylimmällä tasolla on jo ”{name}”. Yhdistetäänkö ne?",
"Trained on: {provenance}": "Koulutettu aineistolla: {provenance}",
"Undo {}": "Kumoa {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Tuntemattomat tunnisteet{s:.0} {} pysyvät sellaisinaan{:.0} (katso Tunnisteet)",
"Update “{name}” with Current Settings": "Päivitä ”{name}” nykyisillä asetuksilla",
"Updated {name}": "{name} päivitetty",
"Updated “{name}”": "”{name}” päivitetty",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versio {} — clean-room-menetelmällä tehty, puhtaasti Rustilla toteutettu kuvakirjasto ja raw-kehitin.",
"Version: {}": "Versio: {}",
"Virtual copy “{name}” of {of}": "Virtuaalikopio ”{name}” kuvasta {of}",
"Working on {what} for {} photos…": "Käsitellään {what} {} kuvalle…",
"XMP adjustments were not imported for {path}: {fields}.": "XMP-säätöjä ei tuotu kohteelle {path}: {fields}.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-profiilia ”{profile}” ei käytetty kohteelle {path}; värit voivat poiketa Lightroomista.",
"new, inside {parent}": "uusi, kohteen {parent} sisällä",
"none (CPU rendering): {why}": "ei mitään (suoritinrenderöinti): {why}",
"not added to Apple Photos: {e}": "ei lisätty Apple Kuviin: {e}",
"the {n} selected photos": "valitut kuvat ({n})",
"the {} photos in view": "näkymän kuvat ({})",
"these {count} photos": "nämä kuvat ({count})",
"{:.1} MB": "{:.1} Mt",
"{count} more warnings.": "Varoituksia lisäksi: {count}.",
"{count} smart preview{} · {:.1} MB": "Älyesikatseluita: {count}{:.0} · {:.1} Mt",
"{done} of {total}": "{done} / {total}",
"{done} of {total} MB": "{done} / {total} Mt",
"{file} · {size}": "{file} · {size}",
"{f} folders (and their subfolders)": "Kansioita: {f} (ja niiden alikansiot)",
"{label} Grading": "{label}: väriluokittelu",
"{label} {d:+} on every selected photo": "{label} {d:+} jokaisessa valitussa kuvassa",
"{label} {index} of {n}": "{label} {index} / {n}",
"{label}, {n} photos": "{label}, kuvia: {n}",
"{matching} of {t} photos": "Kuvia: {matching} / {t}",
"{matching} photos": "Kuvia: {matching}",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Puuttuvia alkuperäisiä: {missing}. Niiden katalogitietueet ovat käytettävissä; yhdistä ne uudelleen komennolla Etsi puuttuvat kuvat.",
"{mode} spot {} of {}": "{mode} täplä {} / {}",
"{month}": "{month}",
"{month} {year}": "{month}/{year}",
"{msg} ({failed} failed)": "{msg} ({failed} epäonnistui)",
"{name} Label": "Merkintä: {name}",
"{name} Off": "{name} pois",
"{name} On": "{name} päällä",
"{name} opened for editing; it is stacked with the original": "{name} avattu muokattavaksi; se pinotaan alkuperäisen kanssa",
"{name} opened in the other editor": "{name} avattu toisessa muokkausohjelmassa",
"{name} · {terms}": "{name} · {terms}",
"{name} — double-click to reset it": "{name} — kaksoisnapsauta nollataksesi sen",
"{n} JPEG files": "JPEG-tiedostoja: {n}",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Ohitettuja paikallisia kuvia: {n}{:.0}: albumit sisältävät kirjaston kuvia (lisää ensin Omiin kuviin)",
"{n} added to Apple Photos": "{n} lisätty Apple Kuviin",
"{n} added to Apple Photos album “{album}”": "{n} lisätty Apple Kuvien albumiin “{album}”",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Muutoksia, joita ei voitu kirjoittaa levylle: {n}{:.0}: {e}\nJos lopetat nyt, menetät ne.{:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Muistiin tallennettuja, levylle kirjoittamattomia muutoksia: {n}{:.0}: {e}\nLightCraft yrittää automaattisesti uudelleen; nyt lopettaminen menettäisi ne.{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Muistiin tallennettuja, levylle kirjoittamattomia muutoksia: {n}{:.0}: {e} — LightCraft yrittää uudelleen",
"{n} found · {sel} selected": "Löytyi: {n} · valittu: {sel}",
"{n} originals opened in the other editor": "Toisessa muokkausohjelmassa avattuja alkuperäisiä: {n}",
"{n} pages": "Sivuja: {n}",
"{n} photos": "Kuvia: {n}",
"{n} photos in view · {slides} slides · {time}": "Kuvia näkymässä: {n} · dioja: {slides} · {time}",
"{n} photos in {} are hidden by the filters above": "Yllä olevat suodattimet piilottavat kuvia kohteessa {}: {n}",
"{n} photos opened for editing; each is stacked with its original": "Muokattavaksi avattuja kuvia: {n}; kukin pinotaan alkuperäisensä kanssa",
"{n} photo{}": "Kuvia: {n}{:.0}",
"{n} photo{} match · updates automatically as photos change": "Täsmääviä kuvia: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa",
"{n} photo{} now · updates automatically as photos change": "Kuvia nyt: {n}{:.0} · päivittyy automaattisesti kuvien muuttuessa",
"{n} preset{}": "Esiasetuksia: {n}{:.0}",
"{n} raws can now be developed from their raw data": "Raw-kuvia, jotka voidaan nyt kehittää raakadatastaan: {n}",
"{n} selected photos · {pages} pages": "Valittuja kuvia: {n} · sivuja: {pages}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "Samankaltaisia kuvia: {n}{:.0} · Näytä → Tyhjennä suodattimet näyttääksesi kaikki",
"{n} tasks are still running: {names}. Quitting stops them.": "Käynnissä olevia tehtäviä: {n}: {names}. Lopettaminen pysäyttää ne.",
"{n} {noun}": "{n} {noun}",
"{pct} %": "{pct} %",
"{photos} catalog records processed; {collections} collections.": "Käsiteltyjä katalogitietueita: {photos}; kokoelmia: {collections}.",
"{pid} (missing)": "{pid} (puuttuu)",
"{sel_n} selected · {counted}": "Valittu: {sel_n} · {counted}",
"{size} GB": "{size} Gt",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{s} s of AV1 video": "{s} s AV1-videota",
"{verb} {n} Photo{}": "{verb} kuvia: {n}{:.0}",
"{weekday}, {day}": "{weekday} {day}.",
"{weekday}, {day} {month} {year}": "{weekday} {day}.{month}.{year}",
"{what} merge added": "{what}: yhdistetty kuva lisätty",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: yhdistetty kuva lisätty koossa {mp}, joka on suurin mahdollinen panoraama (täysi koko on {full})",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: yhdistetty kuva lisätty koossa {mp}: sen täysi koko, {full}, vaatisi enemmän muistia kuin tällä tietokoneella on",
"{what} merge cancelled": "{what}: yhdistäminen peruttu",
"{what} merge failed: {e}": "{what}: yhdistäminen epäonnistui: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}}-ohjausmerkit: {}",
"{} KB": "{} kt",
"{} MB": "{} Mt",
"{} and higher": "{} ja ylöspäin",
"{} contributors · {} commits": "Tekijöitä: {} · committeja: {}",
"{} failed": "{} epäonnistui",
"{} files": "Tiedostoja: {}",
"{} files and {f} folder{}": "Tiedostoja: {}, kansioita: {f}{:.0}",
"{} item{}": "Kohteita: {}{:.0}",
"{} label (click more labels to show any of them)": "Merkintä: {} (napsauta lisää merkintöjä näyttääksesi minkä tahansa niistä)",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} / 4 apuviivaa — vedä pysty- tai vaakasuoraksi tarkoitettujen viivojen suuntaisesti.",
"{} of {} from {host}": "{} / {} kohteesta {host}",
"{} photos": "Kuvia: {}",
"{} photos · exposures {} EV": "Kuvia: {} · valotukset {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "Kuvia: {} · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "Ei luettavissa olevia kuvia: {}{:.0}",
"{} sample{}": "Näytteitä: {}{:.0}",
"{} sec": "{} s",
"{} spot(s) on this photo": "Täpliä tässä kuvassa: {}",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Säilytetään kirjaston ”denoise”-kansiossa; vanhimmat, joita mikään kuva ei käytä, poistuvat ensin, kun raja tulee vastaan.",
"{} — show their photos": "{} — näytä niiden kuvat",
"· {dups} already in the library": "· {dups} jo kirjastossa",
"· {in_source} identical copies": "· identtisiä kopioita: {in_source}",
"· {n} photo{}": "· kuvia: {n}{:.0}",
"· {trashed} in Recently Deleted": "· {trashed} Äskettäin poistetuissa",
"“{parent}” has a “{name}” already. Merge the two?": "Kohteessa ”{parent}” on jo ”{name}”. Yhdistetäänkö ne?",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” on jo henkilö. Yhdistäminen tekee kahdesta yhden henkilön; Kumoa erottaa ne uudelleen.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… ja {more} lisää",
"…and {more} more": "…ja {more} lisää"
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -403,6 +403,7 @@
"Detail (D)": "Détail (D)",
"Details": "Détails",
"Diagonal": "Diagonale",
"Dimensions": "Dimensions",
"Discard Preview Cache": "Supprimer le cache des aperçus",
"Display P3": "Display P3",
"Display name": "Nom affiché",
@@ -1572,7 +1573,7 @@
"Width": "Largeur",
"Width (% of photo)": "Largeur (% de la photo)",
"Width (px)": "Largeur (px)",
"Width × Height": "Largeur × hauteur",
"Width & Height": "Largeur & hauteur",
"Will be replaced": "Sera remplacée",
"Will be restored": "Sera restaurée",
"Window": "Fenêtre",
+300
View File
@@ -0,0 +1,300 @@
{
" · {skipped} skipped (file exists)": " · {skipped} dilewati (file sudah ada)",
" · {} duplicate{} skipped": " · {} duplikat dilewati{:.0}",
" · {} failed: {}": " · {} gagal: {}",
" · {} moved": " · {} dipindahkan",
" · {} not readable": " · {} tidak dapat dibaca",
" · {} restored from Recently Deleted": " · {} dipulihkan dari Baru dihapus",
", {failed} file{} not readable": ", {failed} file tidak dapat dibaca{:.0}",
"Add {local_n} to My Photos": "Tambahkan {local_n} ke Foto saya",
"Add {} Photo{}": "Tambahkan {} foto{:.0}",
"Add {} Photo{}, restore {}": "Tambahkan {} foto{:.0}, pulihkan {}",
"Add “{}” to Favorites": "Tambahkan “{}” ke Favorit",
"Added {n} photo{} to My Photos": "{n} foto{:.0} ditambahkan ke Foto saya",
"Added {n} photo{} to “{}”": "{n} foto ditambahkan ke “{1}”{0:.0}",
"All {n} selected photos move by the same amount.": "Semua {n} foto yang dipilih digeser dengan selisih waktu yang sama.",
"Already in “{}”": "Sudah ada di “{}”",
"Auto Sync: edits apply to {n} photos": "Sinkronisasi otomatis: edit diterapkan ke {n} foto",
"Automatic ({} MB)": "Otomatis ({} MB)",
"Click {}:1": "Klik {}:1",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Ruang warna: Rec. 2020 {curve} (AVIF HDR)",
"Create {n} Virtual Copies": "Buat {n} salinan virtual",
"Decoder: {reason}": "Dekoder: {reason}",
"Delete \"{label}\"": "Hapus \"{label}\"",
"Delete \"{label}\" (and the mask)": "Hapus \"{label}\" (beserta maskernya)",
"Delete {n} Photos": "Hapus {n} foto",
"Delete “{current}”": "Hapus “{current}”",
"Duplicate \"{label}\"": "Duplikat \"{label}\"",
"Example: {example}": "Contoh: {example}",
"Exported {n} photo{}": "{n} foto{:.0} diekspor",
"Exported {ok} of {total} photo{}": "{ok} dari {total} foto diekspor{:.0}",
"Exported {} point curve preset{}": "{} preset kurva titik{:.0} diekspor",
"Exported {} preset{}": "{} preset{:.0} diekspor",
"Exporting {} of {total}": "Mengekspor {} dari {total}",
"Filter bar — {active} active filter{}": "Bilah filter — {active} filter aktif{:.0}",
"Healed {n} dust spot{}": "{n} bintik debu diperbaiki{:.0}",
"Imported {n} point curve preset{}": "{n} preset kurva titik diimpor{:.0}",
"Imported {n} preset{}": "{n} preset{:.0} diimpor",
"Imported {n} preset{}, {f} file{} not readable": "{n} preset diimpor{0:.0}, {f} file tidak dapat dibaca{1:.0}",
"Imported {} photo{}": "{} foto{:.0} diimpor",
"LightCraft could not open its window: {e}": "LightCraft tidak dapat membuka jendela: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft tidak dapat membuka jendela: {e}\n\nDetailnya ada di file log: {path}",
"Matches: {}": "Cocok: {}",
"Merging… {stage} {:.0}%": "Menggabungkan… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "Pindahkan {n} foto{0:.0} ke “{1}”",
"Move {n} photo{} to “{}”?": "Pindahkan {n} foto{0:.0} ke “{1}”?",
"Move {what} to Recently Deleted?": "Pindahkan {what} ke Baru dihapus?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto dipindahkan ke “{1}”{0:.0}. Pilih Edit → Urungkan untuk mengembalikannya.",
"Named ({named_n})": "Bernama ({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "Tempel ke {n} foto{:.0} — hanya menempelkan pengaturan yang disalin",
"Preset: {name}": "Preset: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Hanya pratinjau — yang diedit adalah JPEG bawaan kamera ({})",
"Preview only — {}": "Hanya pratinjau — {}",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Salinan soft proof ({})",
"Proof Preview · {}": "Pratinjau soft proof · {}",
"Quick Develop · {n} photos": "Olah cepat · {n} foto",
"Rated {}": "Diberi rating {}",
"Reading Lightroom catalog… {done} of {total}": "Membaca katalog Lightroom… {done} dari {total}",
"Reading photos… {done} of {total}": "Membaca foto… {done} dari {total}",
"Redo {}": "Ulangi {}",
"Remove “{}” from Favorites": "Keluarkan “{}” dari Favorit",
"Removed from {}": "Dikeluarkan dari {}",
"Rename {n} Photos…": "Ganti nama {n} foto…",
"Rename “{name}”": "Ganti nama “{name}”",
"Rendering on the CPU: {why}": "Merender dengan CPU: {why}",
"Reset {} Channel": "Atur ulang kanal {}",
"Restore {}": "Pulihkan {}",
"Restore {} Photo{}": "Pulihkan {} foto{:.0}",
"Restored {} photo{} from Recently Deleted": "{} foto{:.0} dipulihkan dari Baru dihapus",
"Saved export preset “{}”": "Preset ekspor “{}” disimpan",
"Show {hidden} hidden location{}": "Tampilkan {hidden} lokasi tersembunyi{:.0}",
"Showing {n} of {}": "Menampilkan {n} dari {}",
"Smart album: {}": "Album pintar: {}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "File sidecar XMP untuk {n} foto tidak dapat ditulis (apakah foldernya sedang offline?). LightCraft akan terus mencoba.",
"Undo {}": "Urungkan {}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versi {} — pustaka foto dan pengolah raw yang dibuat secara clean-room dengan Rust murni.",
"Version: {}": "Versi: {}",
"none (CPU rendering): {why}": "tidak ada (rendering CPU): {why}",
"the {n} selected photos": "{n} foto yang dipilih",
"the {} photos in view": "{} foto yang ditampilkan",
"these {count} photos": "{count} foto ini",
"{count} smart preview{} · {:.1} MB": "{count} pratinjau pintar{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} folder (beserta subfoldernya)",
"{label} Grading": "Grading {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} pada setiap foto yang dipilih",
"{label}, {n} photos": "{label}, {n} foto",
"{matching} of {t} photos": "{matching} dari {t} foto",
"{matching} photos": "{matching} foto",
"{mode} spot {} of {}": "{mode}: bintik {} dari {}",
"{msg} ({failed} failed)": "{msg} ({failed} gagal)",
"{name} Label": "Label {name}",
"{name} Off": "{name} nonaktif",
"{name} On": "{name} aktif",
"{name} — double-click to reset it": "{name} — klik dua kali untuk mengatur ulang",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} perubahan{0:.0} tersimpan di memori, tetapi belum ditulis ke disk: {e}\nLightCraft akan otomatis mencoba lagi; jika Anda keluar sekarang, perubahan tersebut akan hilang.{1:.0}",
"{n} found · {sel} selected": "{n} ditemukan · {sel} dipilih",
"{n} photos": "{n} foto",
"{n} photos in {} are hidden by the filters above": "{n} foto di {} disembunyikan oleh filter di atas",
"{n} photo{}": "{n} foto{:.0}",
"{n} photo{} match · updates automatically as photos change": "{n} foto cocok{:.0} · diperbarui otomatis saat foto berubah",
"{n} photo{} now · updates automatically as photos change": "Saat ini {n} foto{:.0} · diperbarui otomatis saat foto berubah",
"{n} selected photos · {pages} pages": "{n} foto dipilih · {pages} halaman",
"{sel_n} selected · {counted}": "{sel_n} dipilih · {counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{what} merge added": "Hasil penggabungan {what} ditambahkan",
"{what} merge failed: {e}": "Penggabungan {what} gagal: {e}",
"{} and higher": "{} ke atas",
"{} files": "{} file",
"{} files and {f} folder{}": "{} file dan {f} folder{:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} dari 4 garis panduan — seret di sepanjang garis yang seharusnya vertikal atau horizontal.",
"{} photos": "{} foto",
"{} photo{} not readable": "{} foto tidak dapat dibaca{:.0}",
"{} spot(s) on this photo": "{} bintik di foto ini",
"· {dups} already in the library": "· {dups} sudah ada di pustaka",
"· {n} photo{}": "· {n} foto{:.0}",
"· {trashed} in Recently Deleted": "· {trashed} di “Baru dihapus”",
"… and {more} more": "… dan {more} lainnya",
"Import cancelled · {} photo{} added": "Impor dibatalkan · {} foto{:.0} ditambahkan",
"Reading photos… {} of {}": "Membaca foto… {} dari {}",
"Adding photos… {} of {}": "Menambahkan foto… {} dari {}",
" ({} in Recently Deleted)": " ({} di Baru dihapus)",
"Already in the library: {} photo{} selected in All Photos": "Sudah ada di pustaka: {} foto{:.0} dipilih di Semua foto",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Sudah ada di pustaka, di Baru dihapus: {} foto{:.0} dipilih · klik kanan → Pulihkan, atau Hapus permanen untuk mengimpor ulang",
"Auto ({})": "Otomatis ({})",
"Build 1:1 Previews ({scope})": "Buat pratinjau 1:1 ({scope})",
"Build Smart Previews ({scope})": "Buat pratinjau pintar ({scope})",
"Build Standard-Sized Previews ({scope})": "Buat pratinjau ukuran standar ({scope})",
"Discard Smart Previews ({scope})": "Buang pratinjau pintar ({scope})",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Katalog Lightroom diperiksa: {photos} foto, {collections} koleksi, {missing} hilang",
"Lightroom import complete: {imported} photos added": "Impor Lightroom selesai: {imported} foto ditambahkan",
"Lightroom import complete: {imported} photos added, {failed} failed": "Impor Lightroom selesai: {imported} foto ditambahkan, {failed} gagal",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Impor Lightroom selesai: {imported} foto ditambahkan; peringatan indeks: {warning}",
"Lightroom import failed: {e}{warning}": "Impor Lightroom gagal: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Pemeriksaan Lightroom gagal: {e}{warning}",
"Label Set: {name}": "Set label: {name}",
"Quick Develop: {name}": "Olah cepat: {name}",
"Reset {name}": "Atur ulang {name}",
"Updated “{name}”": "“{name}” diperbarui",
"{month}": "{month:02}",
"{month} {year}": "{month:02}/{year}",
"{verb} {n} Photo{}": "{verb} {n} foto{:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day:02}/{month:02}/{year}",
"{year}": "{year}",
"Remove “{name}” and its {count} photo{} from the library?": "Keluarkan “{name}” beserta {count} foto{:.0} di dalamnya dari pustaka?",
" ({unsure} with several look-alike files: use Locate)": " ({unsure} memiliki beberapa file serupa: gunakan “Temukan file hilang”)",
" · {:.0}% clipped": " · {:.0}% terpotong",
" · {failed} couldn't be rendered": " · {failed} gagal dirender",
" · {} original{} left at the source": " · {} file asli{:.0} tetap di sumber",
" — not carried over: {}{}": " — tidak ikut terbawa: {}{}",
"+{} more": "+{} lainnya",
"; {kept} already had a location": "; {kept} sudah memiliki lokasi",
"; {outside} outside its time range": "; {outside} di luar rentang waktunya",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} baris (Δ {}), +{} / −{} aset biner, {} – {}",
"Added {what} to {name}": "{what} ditambahkan ke {name}",
"Auto Import: added {} photo{}": "Impor otomatis: {} foto{:.0} ditambahkan",
"Building previews for {} photos…": "Membuat pratinjau untuk {} foto…",
"Couldn't open the editor: {e}": "Tidak dapat membuka editor: {e}",
"Creates {} stacks from {} of {scope}": "Membuat {} tumpukan dari {} di antara {scope}",
"Done ({what}): {done} photo{plural}": "Selesai ({what}): {done} foto{plural:.0}",
"Focus {:.0}": "Fokus {:.0}",
"Folder “{}” (and its subfolders)": "Folder “{}” (beserta subfoldernya)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Ditemukan {n} foto yang hilang{:.0}; {left} masih hilang{unsure}",
"Import failed: {e}": "Impor gagal: {e}",
"Import warnings ({n}):\n{warnings}": "Peringatan impor ({n}):\n{warnings}",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP \"{profile}\" tidak diterapkan untuk {path}; warna mungkin berbeda dari Lightroom.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometri Lightroom untuk {path} tidak dipertahankan; {mode} akan dihitung ulang.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi tersimpan diabaikan karena Tegakkan nonaktif.",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi Tegakkan terpandu yang tersimpan tidak didukung.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometri Lightroom untuk {path} tidak dipertahankan; transformasi Tegakkan yang tersimpan tidak didukung.",
"XMP adjustments were not imported for {path}: {fields}.": "Penyesuaian XMP untuk {path} tidak diimpor: {fields}.",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil diimpor{:.0} (Penjelajah profil → grup masing-masing)",
"Insert at the cursor (also written {})": "Sisipkan di posisi kursor (dapat juga ditulis {})",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Font: {} (OFL). Ikon: orisinal.",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Menerapkan “{k}”: klik foto untuk menambahkan atau menghapusnya · Esc untuk berhenti",
"Previews ready for {done} photo{plural}": "Pratinjau untuk {done} foto{plural:.0} sudah siap",
"Removed {what} from {name}": "{what} dihapus dari {name}",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Mengganti nama “{from}” di {n} foto{:.0} (termasuk kata kunci di bawahnya). Gunakan | untuk tingkatan, mis. Liburan|Italia. Jika nama sudah ada, keduanya akan digabungkan.",
"Replace {} with:": "Ganti {} dengan:",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Menilai fokus dan eksposur {scope}, lalu mencari bidikan serupa yang diambil berselang beberapa detik (bidikan beruntun).",
"Tagged {n} photo{} from the tracklog": "{n} foto diberi lokasi dari log jejak GPS{:.0}",
"The SAM 3 download failed: {e}": "Gagal mengunduh SAM 3: {e}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Tag tak dikenal{s:.0} {0} tetap seperti yang diketik{1:.0} (lihat Tag)",
"Virtual copy “{name}” of {of}": "Salinan virtual “{name}” dari {of}",
"Working on {what} for {} photos…": "Memproses {what} untuk {} foto…",
"{label} {index} of {n}": "{label} {index} dari {n}",
"{name} opened for editing; it is stacked with the original": "{name} dibuka untuk diedit dan ditumpuk dengan file aslinya",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} perubahan{0:.0} tidak dapat ditulis ke disk: {e}\nJika Anda keluar sekarang, perubahan tersebut akan hilang{1:.0}.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} perubahan{:.0} tersimpan di memori, tetapi belum ditulis ke disk: {e} — LightCraft akan mencoba lagi",
"{pid} (missing)": "{pid} (hilang)",
"{{date:…}} directives: {}": "Direktif {{date:…}}: {}",
"{} contributors · {} commits": "{} kontributor · {} commit",
"{} failed": "{} gagal",
"{} item{}": "{} item{:.0}",
"{} photos · exposures {} EV": "{} foto · eksposur {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°",
"{} sample{}": "{} sampel{:.0}",
"{} sec": "{} dtk",
"{} — show their photos": "{} — tampilkan foto orang ini",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft belum dapat mendekode varian raw ini ({}). Anda sedang mengedit pratinjau JPEG bawaan kamera, yang sudah menyertakan gaya gambar kamera (mis. Monokrom) dan keseimbangan putihnya.",
"Filtered: {label}": "Difilter: {label}",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} foto serupa{:.0} · Tampilan → Hapus filter untuk melihat semua",
"Moved; {} photo(s) relinked": "Dipindahkan; {} foto ditautkan ulang",
"{} label (click more labels to show any of them)": "Label {} (klik label lain untuk ikut menampilkan foto berlabel tersebut)",
", {} star{}": ", {} bintang{:.0}",
", {} label": ", label {}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Diunduh dari {host}. Setelah selesai diunduh dan lolos pemeriksaan, LightCraft akan menginstalnya, {after}.",
"{:.1} MB": "{:.1} MB",
"Running on the processor: {}.": "Berjalan di prosesor: {}.",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Disimpan di folder “denoise” pustaka; saat batas tercapai, file terlama yang tidak dipakai foto mana pun dihapus lebih dulu.",
"Denoising {name}: {done} of {total} tiles{}": "Mengurangi noise {name}: {done} dari {total} petak{}",
"{size} GB": "{size} GB",
"{} of {} from {host}": "{} / {} dari {host}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"Making the denoised picture… {}%": "Membuat gambar hasil pengurangan noise… {}%",
"{name} · {terms}": "{name} · {terms}",
"{n} {noun}": "{n} {noun}",
"Trained on: {provenance}": "Dilatih dengan: {provenance}",
" · {queued} waiting": " · {queued} menunggu",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Menampilkan ketentuan model, lalu mengunduh {} dari {host}, menginstalnya, dan mulai menggunakannya. Tidak ada data lain yang dikirim.",
"In line for AI Denoise ({ahead} ahead)…": "Dalam antrean pengurangan noise AI ({ahead} di depan)…",
"Running on {}.": "Berjalan di {}.",
"{file} · {size}": "{file} · {size}",
"• {assumption}": "• {assumption}",
"Put inside “{parent}”": "Masukkan ke “{parent}”",
"Delete the keyword “{name}”?": "Hapus kata kunci “{name}”?",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Kata kunci ini akan dilepas dari {count} foto{:.0}, beserta kata kunci di bawahnya. Urungkan untuk mengembalikannya.",
"Create Keyword Tag Inside “{name}”…": "Buat tag kata kunci di dalam “{name}”…",
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” sudah berisi “{name}”. Gabungkan keduanya?",
"There is a “{name}” at the top level already. Merge the two?": "Sudah ada “{name}” di tingkat teratas. Gabungkan keduanya?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” ditambahkan ke {n} foto{:.0}",
"On {have} of {of} selected photos": "Pada {have} dari {of} foto yang dipilih",
"Remove “{name}”": "Hapus “{name}”",
"There is a keyword set “{name}” already": "Set kata kunci “{name}” sudah ada",
"Added {n} keyword{}": "{n} kata kunci ditambahkan{:.0}",
"; {n} gained synonyms": "; {n} mendapat sinonim baru",
"Exported {n} keyword{}": "{n} kata kunci diekspor{:.0}",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One tidak akan mengimpor daftar yang kata kuncinya mengandung ; , < atau >: {names}",
" — left out, as a keyword list can't hold them: {names}": " — tidak disertakan karena tidak dapat dimasukkan ke daftar kata kunci: {names}",
"{done} of {total}": "{done} dari {total}",
"{done} of {total} MB": "{done} dari {total} MB",
"{pct} %": "{pct} %",
"+{n} more": "+{n} lainnya",
"Create the keyword “{name}”": "Buat kata kunci “{name}”",
"new, inside {parent}": "baru, di dalam {parent}",
"Keywords of {n} selected photos": "Kata kunci {n} foto yang dipilih",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” dihapus dari {n} foto. Edit → Urungkan akan mengembalikannya.",
"1 task is still running: {names}. Quitting stops it.": "1 tugas masih berjalan: {names}. Jika keluar sekarang, tugas ini akan dihentikan.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tugas masih berjalan: {names}. Jika keluar sekarang, semua tugas ini akan dihentikan.",
"{what} merge cancelled": "Penggabungan {what} dibatalkan",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} foto Lokal{:.0} dilewati: album hanya dapat berisi foto pustaka (tambahkan ke Foto saya terlebih dahulu)",
"New Folder in “{name}”": "Folder baru di “{name}”",
"Moved {} photo(s)": "{} foto dipindahkan",
"not added to Apple Photos: {e}": "tidak ditambahkan ke Apple Photos: {e}",
"{n} added to Apple Photos album “{album}”": "{n} ditambahkan ke album Apple Photos “{album}”",
"{n} added to Apple Photos": "{n} ditambahkan ke Apple Photos",
"{n} photos opened for editing; each is stacked with its original": "{n} foto dibuka untuk diedit; masing-masing ditumpuk dengan file aslinya",
" in {g} group{}": " dalam {g} grup{:.0}",
" · kept, not applied: {}": " · dipertahankan, tidak diterapkan: {}",
" — kept but not applied: {}": " — disimpan, tetapi tidak diterapkan: {}",
"Apply to “{mask}” on Selected Photos": "Terapkan ke “{mask}” pada foto yang dipilih",
"Delete the effect preset “{name}”?": "Hapus preset efek “{name}”?",
"Delete these {n} effect presets?": "Hapus {n} preset efek ini?",
"Delete {n} effect preset{}? This can't be undone.": "Hapus {n} preset efek{:.0}? Tindakan ini tidak dapat diurungkan.",
"Effect: {name}": "Efek: {name}",
"Exported {} effect preset{}": "{} preset efek diekspor{:.0}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efek masker{:.0} diimpor (Masker → Efek)",
"Update “{name}” with Current Settings": "Perbarui “{name}” dengan pengaturan saat ini",
"{n} preset{}": "{n} preset{:.0}",
"{name} opened in the other editor": "{name} dibuka di editor lain",
"{n} originals opened in the other editor": "{n} file asli dibuka di editor lain",
"· {in_source} identical copies": "· {in_source} salinan identik",
"{count} more warnings.": "{count} peringatan lainnya.",
"{photos} catalog records processed; {collections} collections.": "{photos} entri katalog diproses; {collections} koleksi.",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} file asli hilang. Data katalognya masih tersedia; gunakan “Cari foto hilang” untuk menautkannya ulang.",
"Applied {name}": "{name} diterapkan",
"Delete “{name}”?": "Hapus “{name}”?",
"Updated {name}": "{name} diperbarui",
"Exported to {path}": "Diekspor ke {path}",
"Saved template {name}": "Templat {name} disimpan",
"Imported {name}": "{name} diimpor",
"Skipped {problem}": "Dilewati: {problem}",
"…and {more} more": "…dan {more} lainnya",
"Template preview: {name}": "Pratinjau templat: {name}",
"{n} photos in view · {slides} slides · {time}": "{n} foto dalam tampilan · {slides} slide · {time}",
"{n} pages": "{n} halaman",
"{n} JPEG files": "{n} file JPEG",
"{s} s of AV1 video": "Video AV1 {s} detik",
"Slideshow exported: {what}": "Tayangan slide diekspor: {what}",
" · {n} skipped (could not be rendered)": " · {n} dilewati (tidak dapat dirender)",
"{n} raws can now be developed from their raw data": "{n} file raw kini dapat diolah dari data raw-nya",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft berhenti tiba-tiba: {e}\n\nDetailnya ada di file log: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft berhenti tiba-tiba: {e}",
"Renames {n} face{} across the library. The files are not changed.": "Mengganti nama {n} wajah{:.0} di seluruh pustaka. File tidak diubah.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” sudah terdaftar sebagai orang. Jika digabungkan, keduanya menjadi satu orang; Urungkan akan memisahkannya lagi.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}: resolusi penuhnya ({full}) memerlukan memori melebihi kapasitas komputer ini",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Hasil penggabungan {what} ditambahkan dengan resolusi {mp}, resolusi maksimum untuk panorama (resolusi penuhnya {full})",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete."
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -405,6 +405,7 @@
"Detail (D)": "Dettaglio (D)",
"Details": "Dettagli",
"Diagonal": "Diagonale",
"Dimensions": "Dimensioni",
"Discard Preview Cache": "Elimina cache delle anteprime",
"Display P3": "Display P3",
"Display name": "Nome visualizzato",
@@ -1610,7 +1611,7 @@
"Width": "Larghezza",
"Width (% of photo)": "Larghezza (% della foto)",
"Width (px)": "Larghezza (px)",
"Width × Height": "Larghezza × altezza",
"Width & Height": "Larghezza & altezza",
"Will be replaced": "Verrà sostituita",
"Will be restored": "Verrà ripristinata",
"Window": "Finestra",
+2 -1
View File
@@ -391,6 +391,7 @@
"Detail (D)": "詳細(D)",
"Details": "詳細",
"Diagonal": "対角線",
"Dimensions": "寸法",
"Discard Preview Cache": "プレビューキャッシュを破棄",
"Display P3": "Display P3",
"Display name": "表示名",
@@ -1500,7 +1501,7 @@
"Width": "幅",
"Width (% of photo)": "幅(写真に対する%)",
"Width (px)": "幅(px)",
"Width × Height": "幅 × 高さ",
"Width & Height": "幅 & 高さ",
"Will be replaced": "置き換えられます",
"Will be restored": "復元されます",
"Window": "ウィンドウ",
+2 -1
View File
@@ -393,6 +393,7 @@
"Detail (D)": "Szczegóły (D)",
"Details": "Szczegóły",
"Diagonal": "Przekątna",
"Dimensions": "Wymiary",
"Discard Preview Cache": "Odrzuć pamięć podręczną podglądów",
"Display P3": "Display P3",
"Display name": "Nazwa wyświetlana",
@@ -1542,7 +1543,7 @@
"Width": "Szerokość",
"Width (% of photo)": "Szerokość (% zdjęcia)",
"Width (px)": "Szerokość (px)",
"Width × Height": "Szerokość × wysokość",
"Width & Height": "Szerokość & wysokość",
"Will be replaced": "Zostanie zastąpiony",
"Will be restored": "Zostanie przywrócony",
"Window": "Okno",
+2 -1
View File
@@ -411,6 +411,7 @@
"Detail (D)": "Detalhe (D)",
"Details": "Detalhes",
"Diagonal": "Diagonal",
"Dimensions": "Dimensões",
"Discard Preview Cache": "Descartar cache de prévias",
"Display P3": "Display P3",
"Display name": "Nome de exibição",
@@ -1595,7 +1596,7 @@
"Width": "Largura",
"Width (% of photo)": "Largura (% da foto)",
"Width (px)": "Largura (px)",
"Width × Height": "Largura × altura",
"Width & Height": "Largura & altura",
"Will be replaced": "Será substituída",
"Will be restored": "Será restaurada",
"Window": "Janela",
+2 -1
View File
@@ -347,6 +347,7 @@
"Detail (D)": "Детализация (D)",
"Details": "Подробности",
"Diagonal": "По диагонали",
"Dimensions": "Размеры",
"Discard Preview Cache": "Удалить кэш просмотров",
"Display P3": "Display P3",
"Display theme": "Тема оформления",
@@ -1340,7 +1341,7 @@
"Width": "Ширина",
"Width (% of photo)": "Ширина (% от фото)",
"Width (px)": "Ширина (px)",
"Width × Height": "Ширина × высота",
"Width & Height": "Ширина & высота",
"Window": "Окно",
"Winter Blue": "Зимняя синева",
"Wrote the XMP sidecars that were waiting": "Ожидавшие XMP-файлы записаны",
+300
View File
@@ -0,0 +1,300 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} med flera likadana filer: använd Leta upp)",
" ({} in Recently Deleted)": " ({} i Nyligen raderade)",
" in {g} group{}": " i {g} grupp{}",
" · kept, not applied: {}": " · behålls men tillämpas inte: {}",
" · {:.0}% clipped": " · {:.0} % klippt",
" · {failed} couldn't be rendered": " · {failed} kunde inte renderas",
" · {n} skipped (could not be rendered)": " · {n} överhoppade (kunde inte renderas)",
" · {queued} waiting": " · {queued} väntar",
" · {skipped} skipped (file exists)": " · {skipped} överhoppade (filen finns)",
" · {} duplicate{} skipped": " · {} dubblett{} överhoppade",
" · {} failed: {}": " · {} misslyckades: {}",
" · {} moved": " · {} flyttade",
" · {} not readable": " · {} kunde inte läsas",
" · {} original{} left at the source": " · {} original{} kvar i källan",
" · {} restored from Recently Deleted": " · {} återställda från Nyligen raderade",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importerar inte en lista med ; , < eller > i ett nyckelord: {names}",
" — kept but not applied: {}": " — behålls men tillämpas inte: {}",
" — left out, as a keyword list can't hold them: {names}": " — utelämnade, eftersom en nyckelordslista inte kan innehålla dem: {names}",
" — not carried over: {}{}": " — fördes inte över: {}{}",
"+{n} more": "+{n} till",
"+{} more": "+{} till",
", {failed} file{} not readable": ", {failed} fil{} kunde inte läsas",
", {} label": ", {} etikett",
", {} star{}": ", {} stjärna{}",
"1 task is still running: {names}. Quitting stops it.": "1 uppgift körs fortfarande: {names}. Om du avslutar stoppas den.",
"; {kept} already had a location": "; {kept} hade redan en plats",
"; {n} gained synonyms": "; {n} fick synonymer",
"; {outside} outside its time range": "; {outside} utanför sitt tidsintervall",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR:er, {} commits, +{} / −{} rader (Δ {}), +{} / −{} binära resurser, {} – {}",
"Add {local_n} to My Photos": "Lägg till {local_n} i Mina foton",
"Add {} Photo{}": "Lägg till {} foto{}",
"Add {} Photo{}, restore {}": "Lägg till {} foto{}, återställ {}",
"Add “{}” to Favorites": "Lägg till ”{}” i Favoriter",
"Added {n} keyword{}": "Lade till {n} nyckelord{}",
"Added {n} photo{} to My Photos": "Lade till {n} foto{} i Mina foton",
"Added {n} photo{} to “{}”": "Lade till {n} foto{} i ”{}”",
"Added {what} to {name}": "Lade till {what} i {name}",
"Added “{keyword}” to {n} photo{}": "Lade till ”{keyword}” på {n} foto{}",
"Adding photos… {} of {}": "Lägger till foton… {} av {}",
"All {n} selected photos move by the same amount.": "Alla {n} markerade foton flyttas lika mycket.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Finns redan i biblioteket, i Nyligen raderade: {} foto{} markerade · högerklicka → Återställ, eller Radera permanent för att importera på nytt",
"Already in the library: {} photo{} selected in All Photos": "Finns redan i biblioteket: {} foto{} markerade i Alla foton",
"Applied {name}": "Tillämpade {name}",
"Apply to “{mask}” on Selected Photos": "Använd på ”{mask}” i markerade foton",
"Auto ({})": "Auto ({})",
"Auto Import: added {} photo{}": "Automatisk import: lade till {} foto{}",
"Auto Sync: edits apply to {n} photos": "Automatisk synkning: ändringar gäller {n} foton",
"Automatic ({} MB)": "Automatisk ({} MB)",
"Build 1:1 Previews ({scope})": "Bygg 1:1-förhandsgranskningar ({scope})",
"Build Smart Previews ({scope})": "Bygg smarta förhandsgranskningar ({scope})",
"Build Standard-Sized Previews ({scope})": "Bygg förhandsgranskningar i standardstorlek ({scope})",
"Building previews for {} photos…": "Bygger förhandsgranskningar för {} foton…",
"Click {}:1": "Klick {}:1",
"Couldn't open the editor: {e}": "Kunde inte öppna redigeraren: {e}",
"Create Keyword Tag Inside “{name}”…": "Skapa nyckelordstagg i ”{name}”…",
"Create the keyword “{name}”": "Skapa nyckelordet ”{name}”",
"Create {n} Virtual Copies": "Skapa {n} virtuella kopior",
"Creates {} stacks from {} of {scope}": "Skapar {} staplar av {} i {scope}",
"Decoder: {reason}": "Avkodare: {reason}",
"Delete \"{label}\"": "Radera ”{label}”",
"Delete \"{label}\" (and the mask)": "Radera ”{label}” (och masken)",
"Delete the effect preset “{name}”?": "Radera effektförinställningen ”{name}”?",
"Delete the keyword “{name}”?": "Radera nyckelordet ”{name}”?",
"Delete these {n} effect presets?": "Radera dessa {n} effektförinställningar?",
"Delete {n} effect preset{}? This can't be undone.": "Radera {n} effektförinställning{}? Det går inte att ångra.",
"Delete {n} Photos": "Radera {n} foton",
"Delete “{current}”": "Radera ”{current}”",
"Delete “{name}”?": "Radera ”{name}”?",
"Denoising {name}: {done} of {total} tiles{}": "Brusreducerar {name}: {done} av {total} rutor{}",
"Discard Smart Previews ({scope})": "Släng smarta förhandsgranskningar ({scope})",
"Done ({what}): {done} photo{plural}": "Klart ({what}): {done} foto{plural}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Laddas ned från {host}. När den har kommit fram och kontrollerats installerar LightCraft den, {after}.",
"Duplicate \"{label}\"": "Duplicera ”{label}”",
"Effect: {name}": "Effekt: {name}",
"Example: {example}": "Exempel: {example}",
"Exported to {path}": "Exporterade till {path}",
"Exported {n} keyword{}": "Exporterade {n} nyckelord{}",
"Exported {n} photo{}": "Exporterade {n} foto{}",
"Exported {ok} of {total} photo{}": "Exporterade {ok} av {total} foto{}",
"Exported {} effect preset{}": "Exporterade {} effektförinställning{}",
"Exported {} point curve preset{}": "Exporterade {} punktkurvförinställning{}",
"Exported {} preset{}": "Exporterade {} förinställning{}",
"Exporting {} of {total}": "Exporterar {} av {total}",
"Filter bar — {active} active filter{}": "Filterfält — {active} aktiva filter{}",
"Filtered: {label}": "Filtrerat: {label}",
"Focus {:.0}": "Fokus {:.0}",
"Folder “{}” (and its subfolders)": "Mappen ”{}” (och dess undermappar)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Hittade {n} saknade foto{}; {left} saknas fortfarande{unsure}",
"Healed {n} dust spot{}": "Retuscherade {n} dammfläck{}",
"Import cancelled · {} photo{} added": "Importen avbröts · {} foto{} tillagda",
"Import failed: {e}": "Importen misslyckades: {e}",
"Import warnings ({n}):\n{warnings}": "Importvarningar ({n}):\n{warnings}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Importerade {e} maskeffekt{} (Maskering → Effekt)",
"Imported {name}": "Importerade {name}",
"Imported {n} point curve preset{}": "Importerade {n} punktkurvförinställning{}",
"Imported {n} preset{}": "Importerade {n} förinställning{}",
"Imported {n} preset{}, {f} file{} not readable": "Importerade {n} förinställning{}, {f} fil{} kunde inte läsas",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importerade {profiles} profil{} (Profilbläddrare → deras grupper)",
"Imported {} photo{}": "Importerade {} foto{}",
"In line for AI Denoise ({ahead} ahead)…": "I kö för AI-brusreducering ({ahead} före)…",
"Insert at the cursor (also written {})": "Infoga vid textmarkören (skrivs även {})",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Det tas bort från {count} foto{}, tillsammans med nyckelorden under det. Ångra återställer det.",
"Keywords of {n} selected photos": "Nyckelord för {n} markerade foton",
"Label Set: {name}": "Etikettuppsättning: {name}",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kan inte avkoda den här raw-varianten än ({}). Du redigerar kamerans inbäddade JPEG-förhandsgranskning, som redan innehåller kamerans bildstil (t.ex. Monokrom) och vitbalans.",
"LightCraft could not open its window: {e}": "LightCraft kunde inte öppna sitt fönster: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft kunde inte öppna sitt fönster: {e}\n\nDetaljer finns i loggfilen: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft avslutades oväntat: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft avslutades oväntat: {e}\n\nDetaljer finns i loggfilen: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-katalogen har granskats: {photos} foton, {collections} samlingar, {missing} saknas",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Lightroom-geometrin för {path} bevarades inte; sparad transformering med guidad Upright stöds inte.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-geometrin för {path} bevarades inte; den sparade transformeringen ignoreras eftersom Upright är av.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Lightroom-geometrin för {path} bevarades inte; sparad Upright-transformering stöds inte.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-geometrin bevarades inte för {path}; {mode} beräknas om.",
"Lightroom import complete: {imported} photos added": "Lightroom-importen är klar: {imported} foton har lagts till",
"Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-importen är klar: {imported} foton har lagts till, {failed} misslyckades",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Lightroom-importen är klar: {imported} foton har lagts till; indexvarning: {warning}",
"Lightroom import failed: {e}{warning}": "Lightroom-importen misslyckades: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Granskningen av Lightroom misslyckades: {e}{warning}",
"Making the denoised picture… {}%": "Skapar den brusreducerade bilden… {}%",
"Matches: {}": "Matchar: {}",
"Merging… {stage} {:.0}%": "Sammanfogar… {stage} {:.0}%",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Typsnitt: {} (OFL). Ikoner: original.",
"Move {what} to Recently Deleted?": "Vill du flytta {what} till Nyligen raderade?",
"Moved {} photo(s)": "Flyttade {} foto(n)",
"Moved; {} photo(s) relinked": "Flyttat; {} foto(n) har länkats om",
"Named ({named_n})": "Namngivna ({named_n})",
"New Folder in “{name}”": "Ny mapp i ”{name}”",
"new, inside {parent}": "ny, inuti {parent}",
"none (CPU rendering): {why}": "ingen (CPU-rendering): {why}",
"not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}",
"On {have} of {of} selected photos": "På {have} av {of} markerade foton",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Målar ”{k}”: klicka på foton för att lägga till eller ta bort det · Esc avslutar",
"Paste into {n} photo{} — only settings that were copied are pasted": "Klistra in i {n} foto{} – endast inställningar som kopierades klistras in",
"Preset: {name}": "Förinställning: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Endast förhandsgranskning – du redigerar kamerans inbäddade JPEG ({})",
"Preview only — {}": "Endast förhandsgranskning – {}",
"Previews ready for {done} photo{plural}": "Förhandsgranskningar klara för {done} foto{plural}",
"Profile: {}": "Profil: {}",
"Proof Copy ({})": "Korrekturkopia ({})",
"Proof Preview · {}": "Korrekturförhandsgranskning · {}",
"Put inside “{parent}”": "Placera i ”{parent}”",
"Quick Develop · {n} photos": "Snabbframkalla · {n} foton",
"Quick Develop: {name}": "Snabbframkalla: {name}",
"Rated {}": "Betygsatt {}",
"Reading Lightroom catalog… {done} of {total}": "Läser Lightroom-katalogen… {done} av {total}",
"Reading photos… {done} of {total}": "Läser foton… {done} av {total}",
"Reading photos… {} of {}": "Läser foton… {} av {}",
"Redo {}": "Gör om {}",
"Remove “{name}”": "Ta bort ”{name}”",
"Remove “{name}” and its {count} photo{} from the library?": "Vill du ta bort ”{name}” och dess {count} foto{} från biblioteket?",
"Remove “{}” from Favorites": "Ta bort ”{}” från Favoriter",
"Removed from {}": "Borttaget från {}",
"Removed {what} from {name}": "Tog bort {what} från {name}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Tog bort ”{name}” från {n} foton. Redigera → Ångra lägger tillbaka det.",
"Rename {n} Photos…": "Byt namn på {n} foton…",
"Rename “{name}”": "Byt namn på ”{name}”",
"Renames {n} face{} across the library. The files are not changed.": "Byter namn på {n} ansikte{} i hela biblioteket. Filerna ändras inte.",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Byter namn på ”{from}” på {n} foto{} (även nyckelord under det). Använd | för nivåer, t.ex. Resor|Italien. Ett befintligt namn sammanfogar de två.",
"Rendering on the CPU: {why}": "Renderar på processorn: {why}",
"Replace {} with:": "Ersätt {} med:",
"Reset {name}": "Återställ {name}",
"Reset {} Channel": "Återställ kanalen {}",
"Restore {}": "Återställ {}",
"Restore {} Photo{}": "Återställ {} foto{}",
"Restored {} photo{} from Recently Deleted": "Återställde {} foto{} från Nyligen raderade",
"Running on the processor: {}.": "Körs på processorn: {}.",
"Running on {}.": "Körs på {}.",
"Saved export preset “{}”": "Exportförinställningen ”{}” har sparats",
"Saved template {name}": "Mallen {name} har sparats",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Bedömer {scope} efter fokus och exponering och hittar likartade bilder tagna inom några sekunder från varandra (serier).",
"Show {hidden} hidden location{}": "Visa {hidden} dold plats{}",
"Showing {n} of {}": "Visar {n} av {}",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Visar modellens villkor, hämtar sedan {} från {host}, installerar den och börjar använda den. Inget annat skickas.",
"Skipped {problem}": "Hoppade över {problem}",
"Slideshow exported: {what}": "Bildspelet har exporterats: {what}",
"Smart album: {}": "Smart album: {}",
"Tagged {n} photo{} from the tracklog": "Taggade {n} foto{} från spårloggen",
"Template preview: {name}": "Förhandsgranskning av mall: {name}",
"The SAM 3 download failed: {e}": "Hämtningen av SAM 3 misslyckades: {e}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "XMP-sidofilerna för {n} foton kunde inte skrivas (är mappen offline?). LightCraft fortsätter att försöka.",
"the {n} selected photos": "de {n} markerade fotona",
"the {} photos in view": "de {} fotona i vyn",
"There is a keyword set “{name}” already": "Det finns redan en nyckelordsuppsättning ”{name}”",
"There is a “{name}” at the top level already. Merge the two?": "Det finns redan ett ”{name}” på översta nivån. Vill du sammanfoga dem?",
"these {count} photos": "dessa {count} foton",
"Trained on: {provenance}": "Tränad på: {provenance}",
"Undo {}": "Ångra {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Okända taggar ({}) behåll{s}{} som de skrevs (se Taggar)",
"Update “{name}” with Current Settings": "Uppdatera ”{name}” med aktuella inställningar",
"Updated {name}": "{name} har uppdaterats",
"Updated “{name}”": "”{name}” har uppdaterats",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} – ett fotobibliotek och raw-framkallare i ren Rust, utvecklat enligt clean room-metoden.",
"Version: {}": "Version: {}",
"Virtual copy “{name}” of {of}": "Virtuell kopia ”{name}” av {of}",
"Working on {what} for {} photos…": "Arbetar med {what} för {} foton…",
"XMP adjustments were not imported for {path}: {fields}.": "XMP-justeringar importerades inte för {path}: {fields}.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-profilen \"{profile}\" tillämpades inte för {path}; färgerna kan skilja sig från Lightroom.",
"{:.1} MB": "{:.1} MB",
"{count} more warnings.": "{count} varningar till.",
"{count} smart preview{} · {:.1} MB": "{count} smarta förhandsgranskning{} · {:.1} MB",
"{done} of {total}": "{done} av {total}",
"{done} of {total} MB": "{done} av {total} MB",
"{file} · {size}": "{file} · {size}",
"{f} folders (and their subfolders)": "{f} mappar (och deras undermappar)",
"{label} Grading": "Gradering: {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} på varje markerat foto",
"{label} {index} of {n}": "{label} {index} av {n}",
"{label}, {n} photos": "{label}, {n} foton",
"{matching} of {t} photos": "{matching} av {t} foton",
"{matching} photos": "{matching} foton",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} original saknas. Deras katalogposter finns kvar; använd Sök efter saknade foton för att länka om dem.",
"{mode} spot {} of {}": "{mode} – punkt {} av {}",
"{month}": "{month}",
"{month} {year}": "{month} {year}",
"{msg} ({failed} failed)": "{msg} ({failed} misslyckades)",
"{name} Label": "Etiketten {name}",
"{name} Off": "{name} av",
"{name} On": "{name} på",
"{name} opened for editing; it is stacked with the original": "{name} öppnades för redigering; den är staplad med originalet",
"{name} opened in the other editor": "{name} öppnades i den andra redigeraren",
"{name} · {terms}": "{name} · {terms}",
"{name} — double-click to reset it": "{name} – dubbelklicka för att återställa",
"{n} added to Apple Photos": "{n} tillagda i Apple Photos",
"{n} added to Apple Photos album “{album}”": "{n} har lagts till i Apple Photos-albumet ”{album}”",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "{n} ändring{} kunde inte skrivas till disken: {e}\nOm du avslutar nu går {} förlorade.",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} ändring{} har sparats i minnet men inte skrivits till disken: {e}\nLightCraft försöker igen automatiskt. Om du avslutar nu går {} förlorade.",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} ändring{} har sparats i minnet men inte skrivits till disken: {e} – LightCraft försöker igen",
"{n} found · {sel} selected": "{n} hittade · {sel} markerade",
"{n} JPEG files": "{n} JPEG-filer",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "{n} lokala foto{} hoppades över: album innehåller foton från biblioteket (lägg först till i Mina foton)",
"{n} originals opened in the other editor": "{n} original har öppnats i den andra redigeraren",
"{n} pages": "{n} sidor",
"{n} photos": "{n} foton",
"{n} photos in view · {slides} slides · {time}": "{n} foton i vyn · {slides} bilder · {time}",
"{n} photos in {} are hidden by the filters above": "{n} foton i {} döljs av filtren ovan",
"{n} photos opened for editing; each is stacked with its original": "{n} foton har öppnats för redigering; vart och ett staplas med sitt original",
"{n} photo{}": "{n} foto{}",
"{n} photo{} match · updates automatically as photos change": "{n} foto{} matchar · uppdateras automatiskt när fotona ändras",
"{n} photo{} now · updates automatically as photos change": "{n} foto{} just nu · uppdateras automatiskt när fotona ändras",
"{n} preset{}": "{n} förinställning{}",
"{n} raws can now be developed from their raw data": "{n} raw-filer kan nu framkallas från sina rådata",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} likartade foto{} · Visa → Rensa filter för att se alla",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} uppgifter körs fortfarande: {names}. Om du avslutar stoppas de.",
"{n} {noun}": "{n} {noun}",
"{pct} %": "{pct} %",
"{photos} catalog records processed; {collections} collections.": "{photos} katalogposter bearbetade; {collections} samlingar.",
"{pid} (missing)": "{pid} (saknas)",
"{sel_n} selected · {counted}": "{sel_n} markerade · {counted}",
"{size} GB": "{size} GB",
"{stage}… {:.0}%": "{stage}… {:.0} %",
"{s} s of AV1 video": "{s} s AV1-video",
"{verb} {n} Photo{}": "{verb} {n} foto{}",
"{weekday}, {day}": "{weekday} {day}",
"{weekday}, {day} {month} {year}": "{weekday} {day} {month} {year}",
"{what} merge added": "{what}-sammanfogning tillagd",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-sammanfogning tillagd med {mp}, den största storlek ett panorama kan ha (full storlek är {full})",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-sammanfogning tillagd med {mp}: full storlek, {full}, kräver mer minne än den här datorn har",
"{what} merge cancelled": "{what}-sammanfogning avbruten",
"{what} merge failed: {e}": "{what}-sammanfogning misslyckades: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "{{date:…}}-direktiv: {}",
"{} and higher": "{} och högre",
"{} contributors · {} commits": "{} bidragsgivare · {} commits",
"{} failed": "{} misslyckades",
"{} files": "{} filer",
"{} files and {f} folder{}": "{} filer och {f} mapp{}",
"{} item{}": "{} objekt{}",
"{} KB": "{} kB",
"{} label (click more labels to show any of them)": "Etiketten {} (klicka på fler etiketter för att visa foton med någon av dem)",
"{} MB": "{} MB",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} av 4 stödlinjer – dra längs linjer som ska vara vertikala eller horisontella.",
"{} of {} from {host}": "{} av {} från {host}",
"{} photos": "{} foton",
"{} photos · exposures {} EV": "{} foton · exponeringar {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} foton · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{} foto{} kunde inte läsas",
"{} sample{}": "{} sample{}",
"{} sec": "{} s",
"{} spot(s) on this photo": "{} fläck(ar) på det här fotot",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Sparas i bibliotekets mapp ”denoise”; de äldsta som inget foto använder tas bort först när gränsen nås.",
"{} — show their photos": "{} – visa deras foton",
"· {dups} already in the library": "· {dups} finns redan i biblioteket",
"· {in_source} identical copies": "· {in_source} identiska kopior",
"· {n} photo{}": "· {n} foto{}",
"· {trashed} in Recently Deleted": "· {trashed} i Nyligen raderade",
"“{parent}” has a “{name}” already. Merge the two?": "”{parent}” har redan en ”{name}”. Vill du sammanfoga dem?",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "”{to}” är redan en person. Om du kombinerar dem blir de en person; Ångra skiljer dem åt igen.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… och {more} till",
"…and {more} more": "…och {more} till",
"Already in “{}”": "Already in “{}”",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete."
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -78,6 +78,7 @@
"Deselect All": "Зняти всі виділення",
"Detail": "Деталі",
"Details": "Подробиці",
"Dimensions": "Розміри",
"Display P3": "Display P3",
"Display name": "Відображуване ім’я",
"Distortion": "Спотворення",
@@ -1529,7 +1530,7 @@
"White Balance Selector (W)": "Вибір балансу білого (W)",
"Width (% of photo)": "Ширина (% фото)",
"Width (px)": "Ширина (пікселів)",
"Width × Height": "Ширина × висота",
"Width & Height": "Ширина & висота",
"Will be replaced": "Буде замінено",
"Will be restored": "Буде відновлено",
"Winter Blue": "Зимова синява",
+300
View File
@@ -0,0 +1,300 @@
{
" ({unsure} with several look-alike files: use Locate)": " ({unsure} có nhiều tệp trông giống nhau: dùng Định vị)",
" ({} in Recently Deleted)": " ({} trong Đã xóa gần đây)",
" in {g} group{}": " in {g} group{}",
" · kept, not applied: {}": " · kept, not applied: {}",
" · {:.0}% clipped": " · {:.0}% bị cắt",
" · {failed} couldn't be rendered": " · {failed} không thể kết xuất",
" · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)",
" · {queued} waiting": " · {queued} đang chờ",
" · {skipped} skipped (file exists)": " · {skipped} đã bỏ qua (tệp đã tồn tại)",
" · {} duplicate{} skipped": " · {} bản trùng{:.0} đã bỏ qua",
" · {} failed: {}": " · {} thất bại: {}",
" · {} moved": " · {} đã di chuyển",
" · {} not readable": " · {} không đọc được",
" · {} original{} left at the source": " · {} tệp gốc{:.0} còn ở nguồn",
" · {} restored from Recently Deleted": " · {} đã khôi phục từ Đã xóa gần đây",
" — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One không nhập danh sách có từ khóa chứa ; , < hoặc >: {names}",
" — kept but not applied: {}": " — kept but not applied: {}",
" — left out, as a keyword list can't hold them: {names}": " — bỏ qua vì danh sách từ khóa không chứa được: {names}",
" — not carried over: {}{}": " — không được chuyển sang: {}{}",
"+{n} more": "+{n} nữa",
"+{} more": "+{} nữa",
", {failed} file{} not readable": ", {failed} tệp{:.0} không đọc được",
", {} label": ", nhãn {}",
", {} star{}": ", {} sao{:.0}",
"1 task is still running: {names}. Quitting stops it.": "Còn 1 tác vụ đang chạy: {names}. Thoát sẽ dừng tác vụ.",
"; {kept} already had a location": "; {kept} đã có vị trí",
"; {n} gained synonyms": "; {n} có thêm từ đồng nghĩa",
"; {outside} outside its time range": "; {outside} ngoài khoảng thời gian của tuyến",
"@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} dòng (Δ {}), +{} / −{} tài nguyên nhị phân, {} – {}",
"Add {local_n} to My Photos": "Thêm {local_n} vào Ảnh của tôi",
"Add {} Photo{}": "Thêm {} ảnh{:.0}",
"Add {} Photo{}, restore {}": "Thêm {} ảnh{:.0}, khôi phục {}",
"Add “{}” to Favorites": "Thêm «{}» vào Yêu thích",
"Added {n} keyword{}": "Đã thêm {n} từ khóa{:.0}",
"Added {n} photo{} to My Photos": "Đã thêm {n} ảnh{:.0} vào Ảnh của tôi",
"Added {n} photo{} to “{}”": "Đã thêm {n} ảnh{0:.0} vào «{1}»",
"Added {what} to {name}": "Đã thêm {what} vào {name}",
"Added “{keyword}” to {n} photo{}": "Đã thêm «{keyword}» vào {n} ảnh{:.0}",
"Adding photos… {} of {}": "Đang thêm ảnh… {} / {}",
"All {n} selected photos move by the same amount.": "Tất cả {n} ảnh đã chọn dịch chuyển cùng một lượng.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Đã có trong thư viện, trong Đã xóa gần đây: đã chọn {} ảnh{:.0} · nhấp chuột phải → Khôi phục, hoặc Xóa vĩnh viễn để nhập lại",
"Already in the library: {} photo{} selected in All Photos": "Đã có trong thư viện: đã chọn {} ảnh{:.0} trong Tất cả ảnh",
"Already in “{}”": "Already in “{}”",
"Applied {name}": "Applied {name}",
"Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos",
"Auto ({})": "Tự động ({})",
"Auto Import: added {} photo{}": "Tự động nhập: đã thêm {} ảnh{:.0}",
"Auto Sync: edits apply to {n} photos": "Đồng bộ tự động: chỉnh sửa áp dụng cho {n} ảnh",
"Automatic ({} MB)": "Tự động ({} MB)",
"Build 1:1 Previews ({scope})": "Tạo xem trước 1:1 ({scope})",
"Build Smart Previews ({scope})": "Tạo xem trước thông minh ({scope})",
"Build Standard-Sized Previews ({scope})": "Tạo xem trước cỡ chuẩn ({scope})",
"Building previews for {} photos…": "Đang tạo xem trước cho {} ảnh…",
"Click {}:1": "Nhấp {}:1",
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
"Couldn't open the editor: {e}": "Không thể mở trình chỉnh sửa: {e}",
"Create Keyword Tag Inside “{name}”…": "Tạo từ khóa trong «{name}»…",
"Create the keyword “{name}”": "Create the keyword “{name}”",
"Create {n} Virtual Copies": "Tạo {n} bản sao ảo",
"Creates {} stacks from {} of {scope}": "Tạo {} chồng từ {} trong {scope}",
"Decoder: {reason}": "Bộ giải mã: {reason}",
"Delete \"{label}\"": "Xóa «{label}»",
"Delete \"{label}\" (and the mask)": "Xóa «{label}» (và mặt nạ)",
"Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?",
"Delete the keyword “{name}”?": "Xóa từ khóa «{name}»?",
"Delete these {n} effect presets?": "Delete these {n} effect presets?",
"Delete {n} Photos": "Xóa {n} ảnh",
"Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.",
"Delete “{current}”": "Xóa «{current}»",
"Delete “{name}”?": "Delete “{name}”?",
"Denoising {name}: {done} of {total} tiles{}": "Khử nhiễu {name}: {done} / {total} ô{}",
"Discard Smart Previews ({scope})": "Hủy xem trước thông minh ({scope})",
"Done ({what}): {done} photo{plural}": "Xong ({what}): {done} ảnh{plural:.0}",
"Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Tải từ {host}. Sau khi tải xong và kiểm tra, LightCraft cài đặt, {after}.",
"Duplicate \"{label}\"": "Nhân bản «{label}»",
"Effect: {name}": "Effect: {name}",
"Example: {example}": "Ví dụ: {example}",
"Exported to {path}": "Exported to {path}",
"Exported {n} keyword{}": "Đã xuất {n} từ khóa{:.0}",
"Exported {n} photo{}": "Đã xuất {n} ảnh{:.0}",
"Exported {ok} of {total} photo{}": "Đã xuất {ok} / {total} ảnh{:.0}",
"Exported {} effect preset{}": "Exported {} effect preset{}",
"Exported {} point curve preset{}": "Đã xuất {} thiết lập sẵn đường cong điểm{:.0}",
"Exported {} preset{}": "Đã xuất {} thiết lập sẵn{:.0}",
"Exporting {} of {total}": "Đang xuất {} / {total}",
"Filter bar — {active} active filter{}": "Thanh lọc — {active} bộ lọc đang bật{:.0}",
"Filtered: {label}": "Đã lọc: {label}",
"Focus {:.0}": "Tiêu điểm {:.0}",
"Folder “{}” (and its subfolders)": "Thư mục «{}» (và thư mục con)",
"Found {n} missing photo{}; {left} still missing{unsure}": "Tìm thấy {n} ảnh thiếu{:.0}; còn thiếu {left}{unsure}",
"Healed {n} dust spot{}": "Đã xử lý {n} vết bụi{:.0}",
"Import cancelled · {} photo{} added": "Đã hủy nhập · đã thêm {} ảnh{:.0}",
"Import failed: {e}": "Nhập thất bại: {e}",
"Import warnings ({n}):\n{warnings}": "Cảnh báo khi nhập ({n}):\n{warnings}",
"Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking ▸ Effect)",
"Imported {name}": "Imported {name}",
"Imported {n} point curve preset{}": "Đã nhập {n} thiết lập sẵn đường cong điểm{:.0}",
"Imported {n} preset{}": "Đã nhập {n} thiết lập sẵn{:.0}",
"Imported {n} preset{}, {f} file{} not readable": "Đã nhập {n} thiết lập sẵn{:.0}, {f} tệp{:.0} không đọc được",
"Imported {profiles} profile{} (Profile browser ▸ their groups)": "Đã nhập {profiles} hồ sơ{:.0} (Trình duyệt hồ sơ → nhóm của chúng)",
"Imported {} photo{}": "Đã nhập {} ảnh{:.0}",
"In line for AI Denoise ({ahead} ahead)…": "Đang xếp hàng Khử nhiễu AI (trước bạn: {ahead})…",
"Insert at the cursor (also written {})": "Chèn tại con trỏ (cũng ghi là {})",
"It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Sẽ gỡ khỏi {count} ảnh{:.0}, kèm từ khóa con. Hoàn tác sẽ khôi phục.",
"Keywords of {n} selected photos": "Keywords of {n} selected photos",
"Label Set: {name}": "Bộ nhãn: {name}",
"LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft chưa giải mã được biến thể raw này ({}). Bạn đang chỉnh JPEG xem trước nhúng của máy ảnh, đã gồm style ảnh (ví dụ Monochrome) và cân bằng trắng.",
"LightCraft could not open its window: {e}": "LightCraft không thể mở cửa sổ: {e}",
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft không thể mở cửa sổ: {e}\n\nChi tiết trong tệp nhật ký: {path}",
"LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}",
"LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}",
"Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Đã kiểm tra catalog Lightroom: {photos} ảnh, {collections} bộ sưu tập, {missing} thiếu",
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Hình học Lightroom cho {path} không được giữ; biến đổi Guided Upright đã lưu không được hỗ trợ.",
"Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Hình học Lightroom cho {path} không được giữ; biến đổi Upright đã lưu không được hỗ trợ.",
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Hình học Lightroom cho {path} không được giữ; biến đổi đã lưu bị bỏ qua vì Upright đang tắt.",
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Hình học Lightroom cho {path} không được giữ; {mode} sẽ được tính lại.",
"Lightroom import complete: {imported} photos added": "Nhập Lightroom xong: đã thêm {imported} ảnh",
"Lightroom import complete: {imported} photos added, {failed} failed": "Nhập Lightroom xong: đã thêm {imported} ảnh, {failed} thất bại",
"Lightroom import complete: {imported} photos added; index warning: {warning}": "Nhập Lightroom xong: đã thêm {imported} ảnh; cảnh báo chỉ mục: {warning}",
"Lightroom import failed: {e}{warning}": "Nhập Lightroom thất bại: {e}{warning}",
"Lightroom inspection failed: {e}{warning}": "Kiểm tra Lightroom thất bại: {e}{warning}",
"MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Phông: {} (OFL). Biểu tượng: tác phẩm gốc.",
"Making the denoised picture… {}%": "Đang tạo ảnh đã khử nhiễu… {}%",
"Matches: {}": "Khớp: {}",
"Merging… {stage} {:.0}%": "Đang ghép… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
"Move {what} to Recently Deleted?": "Chuyển {what} vào Đã xóa gần đây?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back.",
"Moved {} photo(s)": "Đã di chuyển {} ảnh",
"Moved; {} photo(s) relinked": "Đã di chuyển; liên kết lại {} ảnh",
"Named ({named_n})": "Có tên ({named_n})",
"New Folder in “{name}”": "Thư mục mới trong «{name}»",
"On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn",
"Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng",
"Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép",
"Preset: {name}": "Thiết lập sẵn: {name}",
"Preview only — editing the camera's embedded JPEG ({})": "Chỉ xem trước — chỉnh sửa JPEG nhúng của máy ảnh ({})",
"Preview only — {}": "Chỉ xem trước — {}",
"Previews ready for {done} photo{plural}": "Xem trước sẵn sàng cho {done} ảnh{plural:.0}",
"Profile: {}": "Hồ sơ: {}",
"Proof Copy ({})": "Bản sao thử ({})",
"Proof Preview · {}": "Xem trước thử · {}",
"Put inside “{parent}”": "Đặt trong «{parent}»",
"Quick Develop · {n} photos": "Phát triển nhanh · {n} ảnh",
"Quick Develop: {name}": "Phát triển nhanh: {name}",
"Rated {}": "Đã xếp hạng {}",
"Reading Lightroom catalog… {done} of {total}": "Đang đọc catalog Lightroom… {done} / {total}",
"Reading photos… {done} of {total}": "Đang đọc ảnh… {done} / {total}",
"Reading photos… {} of {}": "Đang đọc ảnh… {} / {}",
"Redo {}": "Làm lại {}",
"Remove “{name}”": "Gỡ «{name}»",
"Remove “{name}” and its {count} photo{} from the library?": "Gỡ «{name}» và {count} ảnh{:.0} khỏi thư viện?",
"Remove “{}” from Favorites": "Gỡ «{}» khỏi Yêu thích",
"Removed from {}": "Đã gỡ khỏi {}",
"Removed {what} from {name}": "Đã gỡ {what} khỏi {name}",
"Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.",
"Rename {n} Photos…": "Đổi tên {n} ảnh…",
"Rename “{name}”": "Đổi tên «{name}»",
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Đổi tên «{from}» trên {n} ảnh{:.0} (cả từ khóa con). Dùng | cho cấp, ví dụ Du lịch|Ý. Tên đã có sẽ gộp hai mục.",
"Rendering on the CPU: {why}": "Đang kết xuất trên CPU: {why}",
"Replace {} with:": "Thay {} bằng:",
"Reset {name}": "Đặt lại {name}",
"Reset {} Channel": "Đặt lại kênh {}",
"Restore {}": "Khôi phục {}",
"Restore {} Photo{}": "Khôi phục {} ảnh{:.0}",
"Restored {} photo{} from Recently Deleted": "Đã khôi phục {} ảnh{:.0} từ Đã xóa gần đây",
"Running on the processor: {}.": "Chạy trên bộ xử lý: {}.",
"Running on {}.": "Chạy trên {}.",
"Saved export preset “{}”": "Đã lưu thiết lập sẵn xuất «{}»",
"Saved template {name}": "Saved template {name}",
"Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Chấm điểm {scope} theo tiêu điểm và phơi sáng, tìm ảnh tương tự chụp trong vài giây (burst).",
"Show {hidden} hidden location{}": "Hiện {hidden} vị trí ẩn{:.0}",
"Showing {n} of {}": "Hiển thị {n} / {}",
"Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Hiện điều khoản mô hình, rồi tải {} từ {host}, cài và bắt đầu dùng. Không gửi gì khác.",
"Skipped {problem}": "Skipped {problem}",
"Slideshow exported: {what}": "Slideshow exported: {what}",
"Smart album: {}": "Album thông minh: {}",
"Tagged {n} photo{} from the tracklog": "Đã gắn thẻ {n} ảnh{:.0} từ nhật ký tuyến",
"Template preview: {name}": "Template preview: {name}",
"The SAM 3 download failed: {e}": "Tải SAM 3 thất bại: {e}",
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
"There is a keyword set “{name}” already": "Đã có bộ từ khóa «{name}»",
"There is a “{name}” at the top level already. Merge the two?": "Đã có «{name}» ở cấp cao nhất. Gộp hai mục?",
"Trained on: {provenance}": "Huấn luyện trên: {provenance}",
"Undo {}": "Hoàn tác {}",
"Unknown tag{s} {} stay{} as typed (see Tags)": "Thẻ{s:.0} không xác định {} giữ nguyên như đã gõ{:.0} (xem Thẻ)",
"Update “{name}” with Current Settings": "Update “{name}” with Current Settings",
"Updated {name}": "Updated {name}",
"Updated “{name}”": "Đã cập nhật «{name}»",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "Phiên bản {} — thư viện ảnh và trình phát triển raw thuần Rust, clean-room.",
"Version: {}": "Phiên bản: {}",
"Virtual copy “{name}” of {of}": "Bản sao ảo «{name}» của {of}",
"Working on {what} for {} photos…": "Đang xử lý {what} cho {} ảnh…",
"XMP adjustments were not imported for {path}: {fields}.": "Điều chỉnh XMP không được nhập cho {path}: {fields}.",
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Hồ sơ XMP «{profile}» không được áp dụng cho {path}; màu có thể khác Lightroom.",
"new, inside {parent}": "new, inside {parent}",
"none (CPU rendering): {why}": "không (kết xuất CPU): {why}",
"not added to Apple Photos: {e}": "không thêm được vào Apple Photos: {e}",
"the {n} selected photos": "{n} ảnh đã chọn",
"the {} photos in view": "{} ảnh đang hiển thị",
"these {count} photos": "{count} ảnh này",
"{:.1} MB": "{:.1} MB",
"{count} more warnings.": "{count} more warnings.",
"{count} smart preview{} · {:.1} MB": "{count} xem trước thông minh{:.0} · {:.1} MB",
"{done} of {total}": "{done} / {total}",
"{done} of {total} MB": "{done} / {total} MB",
"{file} · {size}": "{file} · {size}",
"{f} folders (and their subfolders)": "{f} thư mục (và thư mục con)",
"{label} Grading": "Phân cấp màu {label}",
"{label} {d:+} on every selected photo": "{label} {d:+} trên mọi ảnh đã chọn",
"{label} {index} of {n}": "{label} {index} / {n}",
"{label}, {n} photos": "{label}, {n} ảnh",
"{matching} of {t} photos": "{matching} / {t} ảnh",
"{matching} photos": "{matching} ảnh",
"{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.",
"{mode} spot {} of {}": "Vết {mode} {} / {}",
"{month}": "Tháng {month}",
"{month} {year}": "Tháng {month} năm {year}",
"{msg} ({failed} failed)": "{msg} ({failed} thất bại)",
"{name} Label": "Nhãn {name}",
"{name} Off": "{name} tắt",
"{name} On": "{name} bật",
"{name} opened for editing; it is stacked with the original": "{name} đã mở để chỉnh sửa; được xếp chồng với bản gốc",
"{name} opened in the other editor": "Đã mở {name} trong trình chỉnh sửa khác",
"{name} · {terms}": "{name} · {terms}",
"{name} — double-click to reset it": "{name} — nhấp đúp để đặt lại",
"{n} JPEG files": "{n} JPEG files",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Đã bỏ qua {n} ảnh cục bộ{0:.0}: album chỉ chứa ảnh thư viện (hãy thêm vào Ảnh của tôi trước)",
"{n} added to Apple Photos": "Đã thêm {n} vào Apple Photos",
"{n} added to Apple Photos album “{album}”": "Đã thêm {n} vào album Apple Photos «{album}»",
"{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Không ghi được {n} thay đổi{0:.0} lên đĩa: {e}\nNếu thoát ngay, sẽ mất.{1:.0}",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Đã lưu {n} thay đổi{:.0} trong bộ nhớ nhưng chưa ghi đĩa: {e}\nLightCraft sẽ thử lại tự động; thoát ngay sẽ mất.{:.0}",
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Đã lưu {n} thay đổi{:.0} trong bộ nhớ nhưng chưa ghi đĩa: {e} — LightCraft sẽ thử lại",
"{n} found · {sel} selected": "Tìm thấy {n} · đã chọn {sel}",
"{n} originals opened in the other editor": "Đã mở {n} bản gốc trong trình chỉnh sửa khác",
"{n} pages": "{n} pages",
"{n} photos": "{n} ảnh",
"{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}",
"{n} photos in {} are hidden by the filters above": "{n} ảnh trong «{}» bị ẩn bởi bộ lọc phía trên",
"{n} photos opened for editing; each is stacked with its original": "Đã mở {n} ảnh để chỉnh sửa; mỗi ảnh xếp chồng với bản gốc",
"{n} photo{}": "{n} ảnh{:.0}",
"{n} photo{} match · updates automatically as photos change": "{n} ảnh{:.0} khớp · tự cập nhật khi ảnh thay đổi",
"{n} photo{} now · updates automatically as photos change": "Hiện {n} ảnh{:.0} · tự cập nhật khi ảnh thay đổi",
"{n} preset{}": "{n} preset{}",
"{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data",
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
"{n} similar photo{} · View ▸ Clear Filters to see all": "{n} ảnh tương tự{:.0} · Xem → Xóa bộ lọc để thấy tất cả",
"{n} tasks are still running: {names}. Quitting stops them.": "Còn {n} tác vụ đang chạy: {names}. Thoát sẽ dừng chúng.",
"{n} {noun}": "{noun}: {n}",
"{pct} %": "{pct} %",
"{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.",
"{pid} (missing)": "{pid} (thiếu)",
"{sel_n} selected · {counted}": "Đã chọn {sel_n} · {counted}",
"{size} GB": "{size} GB",
"{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} catalog images were not imported because their records in the catalog are incomplete.",
"{stage}… {:.0}%": "{stage}… {:.0}%",
"{s} s of AV1 video": "{s} s of AV1 video",
"{verb} {n} Photo{}": "{verb} {n} ảnh{:.0}",
"{weekday}, {day}": "{weekday}, {day}",
"{weekday}, {day} {month} {year}": "{weekday}, {day}/{month}/{year}",
"{what} merge added": "Đã thêm ghép {what}",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"{what} merge cancelled": "Đã hủy ghép {what}",
"{what} merge failed: {e}": "Ghép {what} thất bại: {e}",
"{year}": "{year}",
"{{date:…}} directives: {}": "Chỉ thị {{date:…}}: {}",
"{} KB": "{} KB",
"{} MB": "{} MB",
"{} and higher": "{} trở lên",
"{} contributors · {} commits": "{} người đóng góp · {} commit",
"{} failed": "{} thất bại",
"{} files": "{} tệp",
"{} files and {f} folder{}": "{} tệp và {f} thư mục{:.0}",
"{} item{}": "{} mục{:.0}",
"{} label (click more labels to show any of them)": "Nhãn «{}» (nhấp thêm nhãn để hiện ảnh có bất kỳ nhãn nào)",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} / 4 đường dẫn — kéo dọc các đường cần thẳng đứng hoặc nằm ngang.",
"{} of {} from {host}": "{} / {} từ {host}",
"{} photos": "{} ảnh",
"{} photos · exposures {} EV": "{} ảnh · phơi sáng {} EV",
"{} photos · {} · {:.0}° × {:.0}°": "{} ảnh · {} · {:.0}° × {:.0}°",
"{} photo{} not readable": "{} ảnh{:.0} không đọc được",
"{} sample{}": "{} mẫu{:.0}",
"{} sec": "{} giây",
"{} spot(s) on this photo": "{} vùng trên ảnh này",
"{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Giữ trong thư mục «denoise» của thư viện; khi đạt giới hạn, xóa trước bản cũ nhất không ảnh nào dùng.",
"{} — show their photos": "{} — hiện ảnh của họ",
"· {dups} already in the library": "· {dups} đã có trong thư viện",
"· {in_source} identical copies": "· {in_source} bản sao giống hệt",
"· {n} photo{}": "· {n} ảnh{:.0}",
"· {trashed} in Recently Deleted": "· {trashed} trong Đã xóa gần đây",
"“{parent}” has a “{name}” already. Merge the two?": "«{parent}» đã có «{name}». Gộp hai mục?",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"• {assumption}": "• {assumption}",
"… and {more} more": "… và {more} nữa",
"…and {more} more": "…and {more} more"
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -409,6 +409,7 @@
"Detail (D)": "细节 (D)",
"Details": "详细信息",
"Diagonal": "对角线",
"Dimensions": "尺寸",
"Discard Preview Cache": "丢弃预览缓存",
"Display P3": "Display P3",
"Display name": "显示名称",
@@ -1576,7 +1577,7 @@
"Width": "宽度",
"Width (% of photo)": "宽度(照片百分比)",
"Width (px)": "宽度 (px)",
"Width × Height": "宽度 × 高度",
"Width & Height": "宽度 & 高度",
"Will be replaced": "将替换",
"Will be restored": "将恢复",
"Window": "窗口",
+2 -1
View File
@@ -409,6 +409,7 @@
"Detail (D)": "詳細檢視(D)",
"Details": "詳細資訊",
"Diagonal": "對角線",
"Dimensions": "尺寸",
"Discard Preview Cache": "捨棄預覽快取",
"Display P3": "Display P3",
"Display name": "顯示名稱",
@@ -1576,7 +1577,7 @@
"Width": "寬度",
"Width (% of photo)": "寬度(照片百分比)",
"Width (px)": "寬度(像素)",
"Width × Height": "寬度 × 高度",
"Width & Height": "寬度 & 高度",
"Will be replaced": "將取代",
"Will be restored": "將還原",
"Window": "視窗",
+435 -2
View File
@@ -130,6 +130,43 @@ language_table! {
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
Sv, "sv", "Svenska", "Latn", include_str!("../locales/sv.json");
Fi, "fi", "Suomi", "Latn", include_str!("../locales/fi.json");
Id, "id", "Bahasa Indonesia", "Latn", include_str!("../locales/id.json");
Vi, "vi", "Tiếng Việt", "Latn", include_str!("../locales/vi.json");
}
impl Locale {
/// Every language in the order the Settings row and Edit ▸ Language list them: English first
/// (the source language), then the rest by their own name with accents folded, so Čeština
/// sits under C. Names in other scripts follow the Latin ones in code point order: Cyrillic,
/// Georgian, then the CJK languages.
pub fn for_picker() -> Vec<Self> {
let mut rest: Vec<Self> = Self::ALL.iter().copied().filter(|l| *l != Self::En).collect();
rest.sort_by_cached_key(|l| name_sort_key(l.name()));
std::iter::once(Self::En).chain(rest).collect()
}
}
/// `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()
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
@@ -556,6 +593,45 @@ mod tests {
set_language(Locale::En);
}
#[test]
fn indonesian_switches_persists_formats_dates_and_preserves_values() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.indonesian", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Id);
assert_eq!(Locale::Id.name(), "Bahasa Indonesia");
assert_eq!(Locale::Id.script(), "Latn");
assert_eq!(crate::menubar::checked(&app, "app.language.indonesian"), Some(true));
assert_eq!(tr("Exposure"), "Eksposur");
assert_eq!(tr("My Photos"), "Foto saya");
assert_eq!(date_group_label("2026-09-20", false), "Minggu, 20/09/2026");
assert_eq!(date_group_label("2026-09-20", true), "Minggu, 20");
assert_eq!(date_group_label("2026-09", false), "09/2026");
assert_eq!(date_group_label("2026", false), "2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "Minggu, 20/09/2026 16:04:05");
// Indonesian keeps a noun unchanged after a count ("1 foto", "22 foto"), so the English
// plural suffix is consumed and never shows.
for n in [0, 1, 2, 5, 11, 22] {
assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("{n} foto diimpor"));
}
let name = String::from("Color 1234.56 {title} 日本語");
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("22 foto ditambahkan ke “{name}”"));
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"id""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Id);
let ctx = egui::Context::default();
for tag in ["id", "id-ID", "id_ID.UTF-8", "id-Latn-ID"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Id));
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Id);
}
set_language(Locale::En);
}
#[test]
fn sentence_case_keeps_georgian_lowercase() {
assert_eq!(upper_first("its input is not an image"), "Its input is not an image");
@@ -565,6 +641,46 @@ mod tests {
assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ'));
}
#[test]
fn swedish_switches_persists_and_formats_without_changing_user_values() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.swedish", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Sv);
assert_eq!(Locale::Sv.name(), "Svenska");
assert_eq!(Locale::Sv.script(), "Latn");
assert_eq!(crate::menubar::checked(&app, "app.language.swedish"), Some(true));
assert_eq!(tr("File"), "Fil");
assert_eq!(tr("Develop"), "Framkalla");
assert_eq!(tr("Exposure"), "Exponering");
assert_eq!(date_group_label("2026", false), "2026");
assert!(date_group_label("2026-09-20", false).starts_with("Söndag 20 "), "{}", date_group_label("2026-09-20", false));
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains("\"sv\""), "{saved}");
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Sv);
let ctx = egui::Context::default();
for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "sv-FI"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Sv), "{tag}");
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Sv);
}
let text = painted_text(&ctx, &mut app, Locale::Sv);
assert!(text.contains("Fil") && text.contains("Exponering"), "{text}");
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "åäöÅÄÖé”’".chars() {
assert!(fonts.has_glyph(&font, ch), "missing Swedish glyph {ch}");
}
}
});
set_language(Locale::En);
}
#[test]
fn every_language_has_a_catalog_covering_the_source() {
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
@@ -655,6 +771,9 @@ mod tests {
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Es), "{tag}");
}
for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
assert_eq!(Locale::parse_tag(tag), Some(Locale::Vi), "{tag}");
}
assert_eq!(Locale::parse_tag("xx"), None);
}
@@ -826,6 +945,10 @@ mod tests {
("app.language.czech", Locale::Cs),
("app.language.italian", Locale::It),
("app.language.georgian", Locale::Ka),
("app.language.swedish", Locale::Sv),
("app.language.finnish", Locale::Fi),
("app.language.indonesian", Locale::Id),
("app.language.vietnamese", Locale::Vi),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
@@ -1208,6 +1331,110 @@ mod tests {
set_language(Locale::En);
}
/// Finnish's coverage of every catalog message and display label, reported like Italian's.
#[test]
fn finnish_coverage_of_catalogs_commands_controls_and_rules_is_reported() {
let catalog = Locale::Fi.catalog();
let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect();
let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
if !lacking.is_empty() {
eprintln!("fi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len());
}
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect();
if !missing.is_empty() {
eprintln!("fi lacks {} display label(s) (shown in English): {missing:?}", missing.len());
}
}
#[test]
fn finnish_switches_persists_formats_dates_and_preserves_values() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.finnish", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Fi);
assert_eq!(crate::menubar::checked(&app, "app.language.finnish"), Some(true));
assert_eq!(tr("Light"), "Valo");
assert_eq!(tr("Landscape"), "Maisema");
assert_eq!(tr("Home"), "Kotikansio");
// Finnish dates have no leading zeros and a lower-case weekday; times are 24-hour with colons.
assert_eq!(date_group_label("2026-09-20", false), "sunnuntai 20.9.2026");
assert_eq!(date_group_label("2026-09-20", true), "sunnuntai 20.");
assert_eq!(date_group_label("2026-09", false), "9/2026");
assert_eq!(date_group_label("2026-09", true), "9");
assert_eq!(date_group_label("2026", false), "2026");
assert_eq!(display_time("2026-09-20T16:04:05"), "sunnuntai 20.9.2026 16:04:05");
// A noun after a numeral is in the partitive singular (1 kuva, 2 kuvaa), which the English
// plural suffix cannot choose, so counts follow a label; the suffix is consumed and never shows.
for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] {
assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Tuotuja kuvia: {n}"));
assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("Tiedostoja: {n}, kansioita: {n}"));
let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" });
let expected = if n == 1 { "1 välimuistissa oleva kuva".to_string() } else { format!("{n} välimuistissa olevaa kuvaa") };
assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected);
}
// This trailing argument is queue progress, not an English plural suffix.
let queued = tr_format!(" · {queued} waiting", queued = 5);
assert_eq!(
tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4),
"Kohinanpoisto IMG.CR3: 1 / 4 ruutua · odottaa: 5"
);
let name = "Color {title} 日本語";
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Lisättyjä kuvia kohteeseen ”{name}”: 22"));
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_eq!(builtin_label("Warm Glow", true), "Lämmin hehku");
assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg");
assert_eq!(tr("develop.set"), "develop.set");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"fi""#));
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Fi);
let ctx = egui::Context::default();
for code in ["fi", "fi-FI", "fi_FI.UTF-8", "fi-Latn-FI"] {
assert_eq!(Locale::parse_tag(code), Some(Locale::Fi));
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Fi);
}
set_language(Locale::En);
}
#[test]
fn finnish_panels_dialogs_and_letters_paint_with_bundled_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::Fi);
assert!(text.contains("Omat kuvat") && text.contains("Kaikki kuvat"), "{text}");
for (command, title) in [("app.about", "Tietoja LightCraftista"), ("app.shortcuts", "Pikanäppäimet"), ("app.settings", "Asetukset")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::Fi);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "äöåÄÖÅ”–"
.chars()
.chain(Locale::Fi.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch)))
{
assert!(fonts.has_glyph(&font, ch), "missing Finnish glyph {ch}");
}
}
});
set_language(Locale::En);
}
/// Every command, control, rule and rename label, and every line of What's New, is a message a
/// catalog can translate. What a language lacks is reported, not failed (like
/// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every
@@ -1349,7 +1576,7 @@ mod tests {
}
#[test]
fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() {
fn german_ukrainian_italian_and_finnish_buttons_fit_the_bottom_bar_and_export_dialog() {
fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) {
match shape {
egui::epaint::Shape::Text(shape) => {
@@ -1359,7 +1586,7 @@ mod tests {
_ => {}
}
}
for language in [Locale::De, Locale::Uk, Locale::It] {
for language in [Locale::De, Locale::Uk, Locale::It, Locale::Fi] {
let ctx = fonts_ctx(&[]);
let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() };
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
@@ -1636,4 +1863,210 @@ mod tests {
assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
set_language(Locale::En);
}
/// English catalog values Vietnamese keeps on purpose. Grouped by reason:
/// standards/acronyms (`ISO`, `HDR`, …), product names (`LightCraft`, `PhotoCraft`),
/// file-type tokens (`Raw`/`raw`, `video`), look names left as established labels
/// (`Bleach Bypass`, `Vintage`), and machine ids that must stay byte-identical
/// (`publicDomain`, `denoiseModel.accept`). `Album` stays as the common loanword.
/// Every other required label must differ from its English source.
const VI_INTENTIONAL_ENGLISH: &[&str] = &[
"OK",
"ISO",
"RGB",
"HDR",
"JPEG",
"PNG",
"TIFF",
"WebP",
"AVIF",
"HEIF",
"HEIC",
"DNG",
"ICC",
"EXIF",
"XMP",
"GPU",
"MCP",
"SAM",
"RAW",
"Raw",
"raw",
"B&W",
"LUT",
"LightCraft",
"ArtCraft",
"GitHub",
"Discord",
"English",
"日本語",
"繁體中文(台灣)",
"Display P3",
"Bleach Bypass",
"Cross Process",
"HDR…",
"video",
"Video",
"true",
"false",
"Album",
"Vintage",
"publicDomain",
"denoiseModel.accept",
"Panorama",
"Panorama…",
"PhotoCraft",
"SDR",
"Matrix/TRC",
"LUT-based",
];
/// Vietnamese's coverage of every catalog message and display label, reported like Italian's; the
/// messages it has are never left in English.
#[test]
fn vietnamese_covers_catalogs_commands_controls_and_rules() {
let catalog = Locale::Vi.catalog();
assert!(!catalog.is_empty());
let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect();
let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
if !lacking.is_empty() {
eprintln!("vi lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len());
}
let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect();
labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label)));
labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label));
labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label));
for (_, label, kind) in lightcraft_catalog::rules::FIELDS {
labels.push(label);
labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label));
}
labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0));
labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning));
labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES);
for word in crate::date_picker::WEEKDAY_SHORT.iter().chain(&["Pick a date", "Year", "Month", "Day"]) {
labels.push(*word);
}
for day in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] {
labels.push(day);
}
let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect();
if !missing.is_empty() {
eprintln!("vi lacks {} display label(s) (shown in English): {missing:?}", missing.len());
}
for (key, value) in catalog {
if key.contains('|') {
continue;
}
if VI_INTENTIONAL_ENGLISH.contains(&key.as_str()) {
continue;
}
assert!(!value.is_empty(), "vi empty for {key:?}");
// Identical values outside the allowlist are accidental English fallbacks.
if value == key && key.chars().any(|ch| ch.is_ascii_alphabetic()) && key.len() > 1 {
// Single-letter ops / symbols may match; longer shared English must be listed.
assert!(
key.len() <= 2 || key.chars().all(|ch| !ch.is_ascii_alphabetic()),
"vi keeps English for {key:?}; add it to VI_INTENTIONAL_ENGLISH if intentional"
);
}
}
}
#[test]
fn vietnamese_switches_persists_formats_dates_and_preserves_values() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.vietnamese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::Vi);
assert_eq!(crate::menubar::checked(&app, "app.language.vietnamese"), Some(true));
assert_eq!(tr("Light"), "Ánh sáng");
assert_eq!(tr("Exposure"), "Phơi sáng");
assert_eq!(Locale::Vi.name(), "Tiếng Việt");
assert_eq!(tr("Settings"), "Cài đặt");
assert_eq!(tr("Appearance"), "Giao diện");
assert_eq!(tr("My Photos"), "Ảnh của tôi");
assert_eq!(tr("Candidate"), "Ảnh ứng viên");
assert_eq!(tr("Subject"), "Chủ thể");
assert_eq!(date_group_label("2026-09-20", false), "Chủ Nhật, 20/9/2026");
assert_eq!(date_group_label("2026-09", false), "Tháng 9 năm 2026");
assert_eq!(date_group_label("2026-09", true), "Tháng 9");
assert_eq!(display_time("2026-09-20T16:04:05"), "Chủ Nhật, 20/9/2026 16:04:05");
for n in [0, 1, 2, 5, 22] {
let text = tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" });
assert!(text.contains(&n.to_string()) && text.contains("ảnh"), "{n}: {text}");
assert!(!text.contains("photos") && !text.contains("photo"), "{n}: {text}");
}
let name = "Color {title} 日本語";
assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Đã thêm 22 ảnh vào «{name}»"));
assert_eq!(builtin_label("Warm Glow", false), "Warm Glow");
assert_ne!(builtin_label("Warm Glow", true), "Warm Glow");
assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg");
assert_eq!(tr("develop.set"), "develop.set");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"vi""#), "{saved}");
assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Vi);
let ctx = egui::Context::default();
for code in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
assert_eq!(Locale::parse_tag(code), Some(Locale::Vi));
let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") };
assert_eq!(reply["ok"], true);
assert_eq!(language(), Locale::Vi);
}
set_language(Locale::En);
}
#[test]
fn vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts() {
let ctx = fonts_ctx(&[]);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let text = painted_text(&ctx, &mut app, Locale::Vi);
assert!(text.contains("Ảnh của tôi") && text.contains("Tất cả ảnh"), "{text}");
for (command, title) in [("app.about", "Giới thiệu LightCraft"), ("app.shortcuts", "Phím tắt"), ("app.settings", "Cài đặt")] {
app.ui.dialog = None;
app.run(command, serde_json::json!({})).unwrap();
let text = painted_text(&ctx, &mut app, Locale::Vi);
assert!(text.contains(title), "{command}: {text}");
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for ch in "ĂăÂâÊêÔôƠơƯưĐđáàảãạéèẻẽẹíìỉĩịóòỏõọúùủũụýỳỷỹỵ".chars().chain(
Locale::Vi
.catalog()
.values()
.flat_map(|text| text.chars())
.filter(|ch| ('\u{00c0}'..='\u{024f}').contains(ch) || ('\u{1ea0}'..='\u{1ef9}').contains(ch)),
) {
assert!(fonts.has_glyph(&font, ch), "missing Vietnamese glyph {ch}");
}
}
});
set_language(Locale::En);
}
}
#[cfg(test)]
mod picker_order_tests {
use super::*;
#[test]
fn picker_lists_english_first_then_names_alphabetically() {
let order = Locale::for_picker();
assert_eq!(order.first(), Some(&Locale::En));
assert_eq!(order.len(), Locale::ALL.len());
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:?}");
assert_eq!(name_sort_key("Čeština"), "cestina");
}
#[test]
fn language_menu_lists_the_picker_order() {
let menu: Vec<&str> = crate::menus::LANGUAGE_COMMANDS.iter().map(|c| c.1).collect();
let picker: Vec<&str> = Locale::for_picker().into_iter().map(Locale::name).collect();
assert_eq!(menu, picker, "Edit ▸ Language and the Settings row list the languages the same way");
}
}
+19 -9
View File
@@ -15,19 +15,25 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
/// has a matching menu command (also listed through the control channel). Tests check coverage.
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"),
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
// The picker order (`Locale::for_picker`): alphabetical by the language's own name after
// English; Cyrillic, Georgian, then CJK names follow the Latin ones. A test keeps them equal.
("app.language.indonesian", crate::i18n::Locale::Id.name(), None, "Edit>Language"),
("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"),
("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"),
("app.language.spanish", crate::i18n::Locale::Es.name(), None, "Edit>Language"),
("app.language.french", crate::i18n::Locale::Fr.name(), None, "Edit>Language"),
("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"),
("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"),
("app.language.portuguese", crate::i18n::Locale::PtBr.name(), None, "Edit>Language"),
("app.language.finnish", crate::i18n::Locale::Fi.name(), None, "Edit>Language"),
("app.language.swedish", crate::i18n::Locale::Sv.name(), None, "Edit>Language"),
("app.language.vietnamese", crate::i18n::Locale::Vi.name(), None, "Edit>Language"),
("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"),
("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"),
("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"),
("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"),
("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"),
("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
@@ -54,6 +60,10 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
"app.language.czech" => Some(crate::i18n::Locale::Cs),
"app.language.italian" => Some(crate::i18n::Locale::It),
"app.language.georgian" => Some(crate::i18n::Locale::Ka),
"app.language.swedish" => Some(crate::i18n::Locale::Sv),
"app.language.finnish" => Some(crate::i18n::Locale::Fi),
"app.language.indonesian" => Some(crate::i18n::Locale::Id),
"app.language.vietnamese" => Some(crate::i18n::Locale::Vi),
_ => None,
}
}
+6 -5
View File
@@ -2018,12 +2018,13 @@ const PPI: ControlSpec = spec("export.ppi", "Resolution (ppi)", 1.0, 1200.0, 240
/// Image Sizing: full size, or a resize mode and its value(s), don't enlarge, ppi.
fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::export::Resize, ppi: &mut u16) {
use lightcraft_engine::export::ResizeMode as R;
const MODES: [(R, &str); 7] = [
const MODES: [(R, &str); 8] = [
(R::LongEdge, "Long Edge"),
(R::ShortEdge, "Short Edge"),
(R::Width, "Width"),
(R::Height, "Height"),
(R::Dimensions, "Width × Height"),
(R::WidthHeight, "Width & Height"),
(R::Dimensions, "Dimensions"),
(R::Megapixels, "Megapixels"),
(R::Percent, "Percentage"),
];
@@ -2047,7 +2048,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
_ if matches!(before, R::Megapixels | R::Percent) => 2048.0,
_ => r.value,
};
if r.mode == R::Dimensions && r.height == 0 {
if matches!(r.mode, R::Dimensions | R::WidthHeight) && r.height == 0 {
r.height = r.value as u32;
}
}
@@ -2056,13 +2057,13 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex
let spec = match r.mode {
R::Megapixels => &SIZE_MP,
R::Percent => &SIZE_PCT,
R::Dimensions => &SIZE_W,
R::Dimensions | R::WidthHeight => &SIZE_W,
_ => &SIZE_PX,
};
if num(ui, spec, &mut v) {
r.value = v as f32;
}
if r.mode == R::Dimensions {
if matches!(r.mode, R::Dimensions | R::WidthHeight) {
let mut h = r.height as f64;
if num(ui, &SIZE_H, &mut h) {
r.height = h as u32;
+2 -2
View File
@@ -621,13 +621,13 @@ mod tests {
"Export Keywords…",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs"] {
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] {
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
for label in labels {
assert!(catalog.contains_key(label), "{code}: “{label}”");
}
}
assert_eq!(Locale::ALL.len(), 14, "a language added: list its file above");
assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above");
}
}
+1 -1
View File
@@ -106,7 +106,7 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options:
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
row(ui, t, crate::i18n::tr("Language"), |ui| {
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
let languages: Vec<_> = crate::i18n::Locale::for_picker().into_iter().map(|language| (language, language.name())).collect();
// Thirteen language names do not fit one line in a narrow window: wrap them.
ui.horizontal_wrapped(|ui| {
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
+5 -3
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -35,13 +35,13 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-18) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) |
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
| 21 | **Localization**: 10 of 16 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages and id every message but What's New, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 135–239 (four new) | [localization-parity](localization-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
| 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) |
| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) |
| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) |
| 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) |
| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) |
| 28 | **Small Classic shortcuts and keys**: ⌘L (Enable Filters), Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) |
@@ -81,6 +81,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|---|---|---|
| 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) |
| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged |
| 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h |
| 2026-10-19 | minor | Gap 27: the released 0.9.1 x64 MSI is tested on Windows 11 Pro 24H2 (a fresh install and an upgrade from 0.8.0 end on the completion page, so #790 is fixed; the upgrade keeps a custom folder; Launch starts the app; removal leaves nothing behind); uninstalling from Settings → Apps still ends with no confirmation (#892, #931); hours unchanged |
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged |
+62
View File
@@ -0,0 +1,62 @@
# Suomi
Suomen (`fi`, englanniksi **Finnish**, omakielinen nimi **Suomi**) saa käyttöön valitsemalla
**Muokkaa → Kieli → Suomi** tai **Asetukset → Yleiset → Kieli**. Muutos tulee voimaan heti ja
tallentuu seuraavaa käynnistystä varten. Komento on `app.language.finnish`; ohjauskanava
hyväksyy `ui.set {"language":"fi"}` sekä tunnisteet `fi-FI` ja `fi_FI.UTF-8`. Ikkunattomassa
ajossa voi käyttää `LIGHTCRAFT_LANGUAGE=fi`.
Luettelot `crates/ui-egui/locales/fi.json` ja `fi-formats.json` on laadittu tekoälyavusteisesti
(Claude) englanninkielisten lähdemerkkijonojen merkityksestä, kokonaisina fraaseina. Avainlista
on kaikkien muiden luetteloiden yhdiste. Suomenkielinen äidinkielinen puhuja (@roopeg) tarkistaa
käännöksen; ammattikääntäjä ei ole sitä käynyt läpi.
## Käytännöt ja laajuus
- Valikot: Tiedosto, Muokkaa, Näytä, Kuva, Ikkuna, Ohje.
- Library = Kirjasto, Develop = Kehitys, Collection = Kokoelma, Album = Albumi,
Preset = Esiasetus, Catalog = Katalogi (Lightroom-katalogi; keyword and model lists stay
”luettelo”), Snapshot = Tilannevedos, Version = Versio, Print (module) = Tulostus, the
command Print… = Tulosta….
- Exposure = Valotus, Contrast = Kontrasti, Clarity = Selkeys, Texture = Tekstuuri,
Dehaze = Sumunpoisto, Vibrance = Eloisuus, Saturation = Värikylläisyys, Hue = Sävy,
Tint = Värisävy, Temperature = Lämpötila, White Balance = Valkotasapaino.
- Highlights = Huippuvalot, Shadows = Varjot, Whites = Valkoiset, Blacks = Mustat.
- Sharpening = Terävöinti, Noise Reduction = Kohinanpoisto, Grain = Rakeisuus,
Vignette = Vinjetti, Color Grading = Väriluokittelu, Tone Curve = Sävykäyrä,
Defringe = Värivirheen korjaus, Feather = Häivytys.
- Crop = Rajaus, Straighten = Suorista, Mask = Maski, Brush = Sivellin,
Radial = Säteittäinen, Linear Gradient = Lineaarinen liukuväri,
Soft Proofing = Vedoksen esikatselu.
- Spot removal: Spot = Täplä, Heal = Paranna, Clone = Kloonaa. Point Color = Pistevärit and
Point Curve = Pistekäyrä keep ”piste”.
- Flag = Lippu, Pick = Valitse (picked photo = Valittu kuva), Reject = Hylätty, Rating = Luokitus (Star rating =
Tähtiluokitus), Label = Merkintä, Color Label = Värimerkintä, Keyword = Avainsana,
Smart Album = Älyalbumi, Filmstrip = Filminauha, Virtual Copy = Virtuaalikopio,
Stack = Pino, Template = Mallipohja (AI Model = Malli), Detection = Havaitseminen, Recently Deleted = Äskettäin poistetut, My Photos = Omat kuvat.
- Päivämäärä on muodossa `20.9.2026` (ilman etunollia), viikonpäivä alkaa pienellä kirjaimella
(`sunnuntai 20.9.2026`), kuukausi–vuosi on `9/2026` ja kellonaika 24-tuntinen `16:04:05`.
- Yksiköt: `kt`, `Mt`, `Gt`; prosentin edessä on väli (`50 %`).
- Määrät kirjoitetaan sanan jälkeen (”Tuotuja kuvia: 22”, ”Odottavia kuvia: 2”), koska
suomessa lukusanan jälkeinen substantiivi on yksikön partitiivissa (1 kuva, 2 kuvaa), eikä
englannin monikkopääte `s` voi valita muotoa. Englannin pääte kulutetaan `{:.0}`-merkinnällä.
Kun lause taipuu yksikössä kaikille luvuille (”{n} kuvalta”, ”{n} kuvassa”), luku voi olla
lauseen sisällä.
- Mukana toimitettu Inter-fontti sisältää ä, ö ja å sekä lainausmerkit ” ”. Lisäfonttia ei
tarvita.
Mitä uutta -ikkunan versiotiedotteet jäävät englanniksi, kuten useimmissa luetteloissa. Alemman
tason tekniset virheilmoitukset ja osa käännöskerroksen ulkopuolisista viesteistä jäävät
englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähdemerkkijonoja.
## Testaus
`i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta,
kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja
suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa.
Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading),
”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing),
”Älyalbumi”, ”Pino” (Stack), ”Pistevärit” (Point Color), ”Tekoälykohinanpoisto” sekä
luovien esiasetusten nimet. Kielen lisäämisen ohje on tiedostossa
[localization.md](localization.md).
+63
View File
@@ -0,0 +1,63 @@
# LightCraft in Indonesian
Choose **Edit → Language → Bahasa Indonesia** or **Settings → General → Language**. The choice applies
at once and is saved for the next launch (`language: "id"` in `ui.json`); the control command is
`app.language.indonesian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `id`, `id-ID`
and `id_ID.UTF-8`; the saved value is always `id`. LightCraft does not follow the system language, for
Indonesian as for every other language.
The Indonesian catalogs cover every plain and formats message the other catalogs have (2,258 + 297),
plus 69 command labels no catalog translated yet: menus, shortcuts, library, editing, crop, masks,
people, import/export, print, slideshow, settings, built-in presets and profiles, history, date
headings, status messages and the smart-album rule editor. The embedded release notes (What's New)
stay in English. Technical errors from lower layers stay in English, as in the other languages.
The catalogs were written with an agent from the meaning of the English labels and reviewed for
natural, standard app Indonesian; 356 labels reuse the Indonesian wording of the sibling apps
(PhotoCraft, VectorCraft, FilmCraft, FigCraft) so the suite reads the same. A native speaker's pass
is welcome.
File names, command ids, template placeholders and albums, presets or metadata you named yourself are
not translated.
## Style
- Sentence case everywhere, as in the English; names, brands and abbreviations keep their capitals.
- Buttons and menu commands use the bare verb (Simpan, Hapus, Ekspor, Pangkas); progress lines use
me- with an ellipsis (Mengimpor…); finished-state toasts put the subject first (`{n} foto diekspor`).
- The user is addressed as *Anda*, only where a sentence needs a subject; never *kamu*.
- Indonesian keeps a noun unchanged after a count (`3 foto`), so English plural endings are dropped
with `{:.0}`.
- Dates read `Minggu, 20/09/2026` (`{weekday}, {day:02}/{month:02}/{year}`), months `09/2026`.
- Delete is **Hapus**; remove from a container (album, stack, favorites) is **Keluarkan dari …**.
Select is **Pilih**; the Pick flag is **Pilihan**.
- `▸` and `▾` are written `→` and `⌄`, as in the other catalogs.
## Terminology
| Source | Indonesian |
|---|---|
| Develop | Olah |
| File / Edit / View | File / Edit / Tampilan |
| Settings / General / Language | Pengaturan / Umum / Bahasa |
| Import / Export | Impor / Ekspor |
| Photos / Albums / Folders | Foto / Album / Folder |
| Smart Album | Album pintar |
| Crop | Pangkas |
| Masks / Masking | Masker |
| Exposure / Contrast | Eksposur / Kontras |
| Highlights / Shadows / Whites / Blacks | Sorotan / Bayangan / Putih / Hitam |
| Temperature / Tint | Suhu / Rona |
| Vibrance / Saturation | Vibrance / Saturasi |
| Clarity / Texture / Dehaze | Kejernihan / Tekstur / Hilangkan kabut |
| Sharpening / Noise Reduction | Penajaman / Pengurangan noise |
| Vignette / Grain | Vinyet / Butiran |
| White Balance / Tone Curve | Keseimbangan putih / Kurva tone |
| Lens Corrections | Koreksi lensa |
| Presets / Profile | Preset / Profil |
| Keywords / Rating | Kata kunci / Rating |
| Flag / Pick / Reject | Tanda / Pilihan / Tolak |
| Stack / Virtual Copy | Tumpukan / Salinan virtual |
| History / Recently Deleted | Riwayat / Baru dihapus |
| Compare / Before / After | Bandingkan / Sebelum / Sesudah |
| Slideshow / Print / Template | Tayangan slide / Cetak / Templat |
| People | Orang |
+19 -9
View File
@@ -1,12 +1,12 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** trivial (Italian: every interface string translated, native-speaker corrections; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-20 · **Change:** minor (German form of address: *du*; Indonesian and Finnish interface catalogs added; Italian: every interface string translated, native-speaker corrections; Vietnamese shipped; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How far each interface language is from `full`. How to add or maintain a language is in
[localization.md](localization.md); per-language notes are in `localization-<code>.md`
([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md),
[pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md),
[ru](localization-ru.md), [ka](localization-ka.md)).
[ru](localization-ru.md), [fi](localization-fi.md), [ka](localization-ka.md), [id](localization-id.md), [vi](localization-vi.md)).
## The target
@@ -18,12 +18,12 @@ go beyond the target.
## How the numbers are measured
**Measured** (a Python count over `crates/ui-egui/locales/*.json`, 2026-10-10):
**Measured** (a Python count over `crates/ui-egui/locales/*.json`, 2026-10-13):
- The denominator is every message any catalog knows: the union of the plain catalogs' keys (2,074
- The denominator is every message any catalog knows: the union of the plain catalogs' keys (2,222
English source strings: menus, commands, controls, panels, dialogs, tooltips, presets, What's New
lines) plus the 230 messages of the formats catalogs (`*-formats.json`: text with runtime values and
date patterns), 2,304 messages in all.
lines) plus the 249 messages of the formats catalogs (`*-formats.json`: text with runtime values and
date patterns), 2,471 messages in all.
- A message counts as translated when the catalog has a non-empty value that differs from the English
key. Some identical values are correct (`ISO`, `OK` in German), so the figure slightly understates
coverage; on the other hand, strings that never reach a catalog (technical errors raised in lower
@@ -41,6 +41,10 @@ Native-speaker review: no catalog has had a formal review. Several catalogs have
contributors whose commits only touch that language (ja, zh-hans, zh-hant, fr, de, ru, uk, pt-br);
that is promising but is not recorded as review, so the column says `no (community edits)`.
Form of address: German speaks to the user with *du* ("Wähle ein Foto", "Dein Name"), never *Sie*.
LightCraft is built by its community, and *du* is how people helping each other talk. A new German
string follows that.
## The twelve key languages
| # | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | To `full` |
@@ -52,11 +56,11 @@ that is promising but is not recorded as review, so the column says `no (communi
| 5 | Arabic | `ar` | 0 (0%) | none | **RTL layout and Arabic shaping missing** | no | none (target: not shipped) | 60–110 h (RTL mirroring 30–50 h + shaping, shared with Hindi) |
| 6 | French | `fr` | 2,315 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) |
| 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h |
| 8 | Indonesian | `id` | 0 (0%) | none | Latin (Inter covers it) | no | none (target: not shipped) | 10–16 h |
| 8 | Indonesian | `id` | 2,555 / 2,555 (100%; 2026-10-19: every plain and formats message any catalog has, plus 69 command labels; What's New left in English) | as zh-hans | Latin (Inter covers it) | no (written with an agent; native-speaker pass pending) | partial (target: not shipped) | 2–4 h (native review, lower-layer errors) |
| 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) |
| 10 | German | `de` | 2,338 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) |
| 10 | German | `de` | 3,644 / 3,754 (97%; 2026-10-16: every interface message and every What's New line except 59: the distinct What's New lines whose English text has a ▸ menu path have no German entry yet and wait for #2016; the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) |
| 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h |
| 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h |
| 12 | Vietnamese | `vi` | 2,471 / 2,471 (100% key coverage of current union; intentional English like `ISO`/`OK` kept) | menus, panels, dialogs, presets, profiles, dates, formats; lower-layer errors English | Latin with stacked diacritics (Inter) | no (community contribution) | partial (catalog complete; lower-layer errors English) | 1–3 h |
## Other languages LightCraft ships (4)
@@ -67,6 +71,7 @@ that is promising but is not recorded as review, so the column says `no (communi
| Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h |
| Italian | `it` | 2,628 / 3,278 (80%, measured 2026-10-15; every interface string is translated, the other 650 are What's New lines, left in English) | Latin (Inter) | partly (written with an agent; corrections and the 2026-10-15 additions by a native speaker) | partial (lower-layer errors English) | 3–5 h |
| Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) |
| Finnish | `fi` | 2,610 / 3,308 (79%, measured 2026-10-17: 2,313 plain + 297 formats; the rest is What's New) | Latin (Inter) | yes (written with an agent; native-speaker review by @roopeg) | partial (lower-layer errors English) | 3–5 h |
Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai
(Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai
@@ -93,9 +98,14 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Indonesian interface catalog (`id`): every plain and formats message any catalog has, plus 69 command labels; What's New left in English |
| 2026-10-17 | minor | Finnish interface catalog (`fi`): 2,610 of 3,308 messages (2,313 plain + 297 formats), What's New left in English |
| 2026-10-15 | trivial | Italian (`it`): 339 interface messages and 60 format strings that were missing or still in English translated (2,628 of 3,278; only What's New left in English); native-speaker corrections: Exposure → *Esposizione*, Sharpening → *Nitidezza*, Preset → *Preset*, blurry → *sfocate* |
| 2026-10-13 | minor | Remeasured denominator (2,222 plain + 249 formats = 2,471); Vietnamese catalog complete at 2,471/2,471 (status partial: lower-layer errors still English) |
| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English |
| 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) |
| 2026-10-12 | minor | Georgian (`ka`) row: every catalog message |
| 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates |
| 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) |
| 2026-10-16 | minor | German (`de`): 217 interface strings and 862 What's New lines added (agent translations, native review welcome); re-measured 2,572 → 3,644 of 3,754 messages (80% → 97%). The denominator grew because German now translates What's New lines no other catalog has; other rows keep their earlier counts |
| 2026-10-20 | minor | German (`de`) speaks to the user with *du*; the 11 messages that used *Sie* changed, no count changes |
+76
View File
@@ -0,0 +1,76 @@
# LightCraft bằng tiếng Việt
Chọn **Chỉnh sửa → Ngôn ngữ → Tiếng Việt** hoặc **Cài đặt → Chung → Ngôn ngữ**.
Lựa chọn áp dụng ngay và được lưu cho lần mở sau (`language: "vi"` trong `ui.json`).
Các tag locale hệ thống sau cũng chọn tiếng Việt: `vi`, `vi-VN`, `vi_VN`, `vi_VN.UTF-8`.
## Phạm vi
Catalog tiếng Việt phủ **toàn bộ** bề mặt localization hiện tại của LightCraft
(đo 2026-10-13: union plain **2 222** + formats **249** = **2 471** thông điệp):
- `crates/ui-egui/locales/vi.json` — thông điệp plain / nhãn hiển thị (2 222 khóa)
- `crates/ui-egui/locales/vi-formats.json` — thông điệp có giá trị runtime và mẫu ngày (249 khóa)
Điều này là **100% bề mặt localizable/runtime hiện tại**, không phải “mọi chuỗi tiếng Anh
trong toàn bộ repository”. Lỗi kỹ thuật từ crate tầng dưới vẫn có thể là tiếng Anh, giống
các ngôn ngữ khác.
## Quy ước thuật ngữ
| English | Vietnamese |
|---|---|
| File / Folder | Tệp / Thư mục |
| Library / Album / Smart Album | Thư viện / Album / Album thông minh |
| Import / Export | Nhập / Xuất |
| Exposure / Contrast | Phơi sáng / Tương phản |
| Highlights / Shadows / Whites / Blacks | Vùng sáng / Vùng tối / Vùng trắng / Vùng đen |
| White Balance / Temperature / Tint | Cân bằng trắng / Nhiệt độ / Tông |
| Vibrance / Saturation | Độ rực rỡ / Độ bão hòa |
| Texture / Clarity / Dehaze / Grain / Vignette | Kết cấu / Độ rõ / Khử sương mù / Hạt / Tối góc |
| Tone Curve | Đường cong tông màu |
| Sharpening / Noise Reduction / AI Denoise | Làm nét / Giảm nhiễu / Khử nhiễu AI |
| Masking / Brush / Linear Gradient | Mặt nạ / Cọ / Chuyển sắc tuyến tính |
| Preset / Profile / History / Version | Thiết lập sẵn / Hồ sơ / Lịch sử / Phiên bản |
| Appearance (theme) | Giao diện |
| Subject (AI) | Chủ thể |
| Candidate / Select | Ảnh ứng viên / Chọn |
| Flag / Picked / Rejected / Unflagged | Gắn cờ / Đã chọn / Đã loại / Không gắn cờ |
| Shutter Speed | Tốc độ màn trập |
| Smart Previews | Xem trước thông minh |
Tên sản phẩm và chuẩn giữ nguyên khi là định danh: LightCraft, ArtCraft, GitHub, Discord,
JPEG, PNG, TIFF, WebP, AVIF, HEIF, HEIC, RAW, DNG, RGB, HDR, ICC, EXIF, XMP, GPU, MCP, SAM, ISO, OK.
Tên album / preset / profile do người dùng tạo, tên tệp, command ID và metadata nhập tay
không được dịch.
## Ngày và số lượng
- Ngày đầy đủ: `{weekday}, {day}/{month}/{year}` (ví dụ `Chủ Nhật, 20/9/2026`)
- Tháng/năm: `Tháng {month} năm {year}`
- Thứ viết tắt trên date picker: T2…T7, CN
- Thông điệp đếm dùng dạng trung tính (`22 ảnh`), không tạo số ít/số nhiều nhân tạo như tiếng Anh
## Font
Tiếng Việt dùng Inter (Latin + dấu). Không cần craft-fonts CJK. Kiểm tra glyph trong
`vietnamese_panels_dialogs_and_diacritics_paint_with_bundled_fonts`.
## Kiểm tra
```sh
cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000
LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800
```
Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album,
đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu.
## Nguồn dịch (clean-room)
Dịch từ chuỗi tiếng Anh nguồn của LightCraft. PhotoCraft / VectorCraft / PDFCraft chỉ dùng
làm bộ nhớ thuật ngữ khi cùng nguồn tiếng Anh và cùng ngữ cảnh UI. Không dùng gói ngôn ngữ
Adobe Lightroom hay tài nguyên độc quyền.
+4 -2
View File
@@ -2,11 +2,11 @@
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes
Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`), Georgian (`ka`), Swedish (`sv`), Finnish (`fi`), Indonesian (`id`) and Vietnamese (`vi`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md),
[`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md),
[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md),
[`localization-cs.md`](localization-cs.md) and [`localization-ka.md`](localization-ka.md).
[`localization-cs.md`](localization-cs.md), [`localization-fi.md`](localization-fi.md), [`localization-ka.md`](localization-ka.md), [`localization-id.md`](localization-id.md) and [`localization-vi.md`](localization-vi.md).
## Adding a language
@@ -34,6 +34,8 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json");
It, "it", "Italiano", "Latn", include_str!("../locales/it.json");
Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json");
Fi, "fi", "Suomi", "Latn", include_str!("../locales/fi.json");
Vi, "vi", "Tiếng Việt", "Latn", include_str!("../locales/vi.json");
}
```
+1 -1
View File
@@ -179,7 +179,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif` / `.jxl` (`jxlLossless`, `jxlEffort`, `jxlProgressive`); `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation (Lightroom's Dimensions); with `fit: "widthHeight"` they limit the width and the height as the photo stands (Width & Height); `dontEnlarge` defaults to true |
`get_develop {id}` and `render_photo {id}` use that photo without changing the selection.
`set_develop`, `crop` and `export` with a supplied photo `id` end the current edit interaction,
+7 -4
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -168,8 +168,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map |
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) |
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) |
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
## F. Edit panel — global adjustments (EDIT)
@@ -391,7 +391,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) |
| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). |
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
| LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) |
| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) |
| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU |
| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | |
@@ -808,6 +808,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged |
| 2026-10-19 | patch | LR-CROP-RECT: on a straightened photo a moved crop slides along the tilted edges, so a box that fills the photo's height reaches its sides (#2299); status unchanged |
| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged |
| 2026-10-18 | patch | LR-META-XMP: Lightroom's profile and version bookkeeping (`CompatibleVersion`, profile digests, lens profile identity, `CropConstrainToUnitSquare`, default-strength lens profile scales) is no longer reported as unimported XMP; status unchanged (✅) |
| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged |
| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) |
| 2026-10-19 | patch | LR-LIB-STACK: the filmstrip's stack badge expands / collapses the stack like the grid's, and a badge click works on a stack that isn't selected (it is selected first). Statuses and feature counts unchanged. |
@@ -849,6 +850,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) |
| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) |
| 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) |
| 2026-10-12 | minor | LR-META-XMP: bounded complete-APP1 manual JPEG reread, fail-closed sidecar precedence and scoped regression coverage; LR-META-EXIF-FULL: reuse loaded bytes; statuses unchanged |
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |
@@ -924,3 +926,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-19 | patch | LRC-LIB-LIBFOLDERS: Synchronize Folder on a folder that is up to date says so with one Close instead of four empty choices and a Synchronize that started an empty job; status unchanged (✅) |
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
+8 -6
View File
@@ -1,6 +1,6 @@
# Target-app parity: LightCraft vs Adobe Lightroom
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327; Platforms: the Windows x64 installer verified on Windows 11) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
@@ -51,10 +51,10 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) |
| File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) |
| Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) |
| Localization (12 key languages) | 7 of 12 present | ~55% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) |
| Localization (12 key languages) | 8 of 12 present | ~64% (mean of the 12, measured) | 130–210 h for the target's set; +135–239 h for Hindi, Arabic, Vietnamese, Korean | [localization-parity.md](localization-parity.md) |
| Performance | ~80% | ~70% | 30–50 h | 24 MP GPU slider 3.9 ms, export 0.3 s (measured, [gpu-pipeline.md](gpu-pipeline.md)); 85k library opens in 1.7–4.7 s; 100k test pending |
| Stability | — | ~60% | 30–60 h | never-crash lints and guards workspace-wide, crash-tested journal, 2,561 tests; but CI runs tests only on FreeBSD, user reports keep finding bugs in ✅ areas |
| Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; Windows installer UI unverified on Windows (#79); web experimental (CPU only) |
| Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; the Windows x64 installer is verified on Windows 11 (0.9.1) except for a confirmation after uninstalling from Settings → Apps (#892); web experimental (CPU only) |
| Ecosystem / plug-ins | ~25% | ~15% | 40–80 h + owner decision | no Lightroom SDK equivalent (export, publish, metadata plug-ins); beyond the target: JSON-lines control channel, MCP server with 28 tools + one per command, CLI |
| AI features | ~30% | ~20% | 120–220 h + model decisions | [AI](#ai) (overlaps the masking and Enhance rows; not added twice) |
@@ -136,12 +136,12 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md):
| UI/UX fidelity | 8% | 70% |
| File formats (non-raw) | 6% | 70% |
| Hardware | 3% | 40% |
| Localization | 3% | 55% |
| Localization | 3% | 64% |
| Performance | 6% | 70% |
| Stability | 8% | 60% |
| Platforms | 4% | 60% |
| Ecosystem / plug-ins | 2% | 15% |
| **Weighted sum** | 100% | **61.2% ≈ 61%** (range 56–66%) |
| **Weighted sum** | 100% | **61.5% ≈ 61%** (range 56–66%) |
Mainstream comes out lower than full: its scope is the raw-develop core, where LightCraft is weakest
(camera colour, CR3 coverage), and its discounts are multiplicative, while the full sum is lifted by
@@ -158,7 +158,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point).
| Discount | Mainstream | Essentials | Evidence |
|---|---:|---:|---|
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714) |
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) |
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer, fixed in 0.9.1); tests run in CI on FreeBSD only (gap 9) |
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) |
| **Product** | **×0.804** | **×0.830** | |
@@ -300,6 +300,8 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Localization ~55% → ~64% (mean of the 12 key languages): Indonesian (`id`) catalog, every message but What's New; full target 61.2% → 61.5% (still ~61%) |
| 2026-10-19 | patch | Platforms: the Windows installer is no longer listed as unverified (the 0.9.1 x64 MSI is tested on Windows 11; a confirmation after uninstalling from Settings → Apps, #892, remains); #790 noted as fixed in 0.9.1; percentages and hours unchanged |
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
+8
View File
@@ -78,6 +78,14 @@
7078 drone panorama's 512 px thumbnail took 38 s and now takes about a second, with the same look; your edits still
apply. Loupe previews larger than the biggest copy, 1:1 views and exports still read the full image.
### Fewer false "not imported" warnings for Lightroom raws
- Importing raws with Lightroom XMP sidecars no longer lists settings Lightroom writes only for itself as adjustments
that weren't imported: `CompatibleVersion`, the camera and lens profile checksums, the lens profile's name, file and
setup, the crop's "constrain to image" option, and the lens profile's strength when it is at its default of 100 %.
A raw sidecar used to name all of them for every photo. Settings LightCraft really doesn't apply, such as `Look`,
are still reported.
### Fill Location from GPS: no more neighbouring town in City
- The offline place names list towns of 15,000 people or more, so a photo taken in a smaller one got the nearest larger
town in City, sometimes another municipality (issue #2242). The Fill Location tooltip and the command help now say so,
+24 -1
View File
@@ -10,7 +10,7 @@ other tools), and let LightCraft pick up edits made elsewhere.
|---|---|
| Name | `<stem>.xmp` by default (`IMG_0001.CR3` → `IMG_0001.xmp`); `<file>.xmp` (`IMG_0001.CR3.xmp`) with `naming: "full"`. Reading accepts either (preferred first) and `.XMP`. With stem naming, files sharing a stem don't share a sidecar: see *Shared names* below. |
| Write | `photo.saveMetadataToFile {ids?}` (Photo ▸ Save Metadata to File, ⌘S), or automatically after every change with `library.xmpPreferences {autoWrite: true}` (File ▸ Automatically Write Changes into XMP). Slider drags are written once, when the drag ends; undo/redo rewrite the sidecar. An existing sidecar is **merged into, never replaced** (see *Saving into an existing sidecar*). Written atomically (temp file, fsync, rename). The result lists `written`, `merged` and `backups`. |
| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). For raw/DNG files without a sidecar, the XMP embedded in the file is used. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). A file arriving in an import is ranked as if it were catalogued already: it reads its own `<file>.xmp` when another file of its stem (a raw first) owns the shared stem sidecar (issue #1905). |
| Read | On import (`library.import`, the report counts `sidecars`), and `photo.readMetadataFromFile {ids?}` (one undo step). Manual rereading uses embedded XMP in raw/DNG and JPEG files when no sidecar exists. A discovered sidecar that cannot be read or parsed is reported in `failed`, without applying embedded values. `photo.readMetadataFromFile` never writes the sidecar, even with auto-write on: writing back what it has just read would add `lc:settings`, which later reads prefer over another app's `crs:` edits (issue #934). A file arriving in an import is ranked as if it were catalogued already: it reads its own `<file>.xmp` when another file of its stem (a raw first) owns the shared stem sidecar (issue #1905). |
| Preferences | `library.xmpPreferences {autoWrite?, naming?: stem\|full}`, stored in the library's `prefs.json`. |
What we write (standard namespaces, so other tools can read the metadata):
@@ -41,6 +41,29 @@ When foreign `crs:` settings are only partly transferable, `library.import` incl
report. Warnings identify unsupported profile names, saved Upright geometry that will be recalculated, and other
unmapped adjustments; they do not change the source packet or become catalog edits.
Manual rereading and undo leave the JPEG original unchanged. Manual rereading also leaves sidecar files unchanged,
even with automatic XMP writing on. With automatic XMP writing off (the default), undo/redo leave sidecars unchanged;
with it enabled, subsequent edits and undo/redo can trigger the existing sidecar writer. `photo.allMetadata` uses
its already loaded file bytes for embedded XMP, with an existing sidecar taking precedence.
Manual JPEG rereading uses the first standard XMP APP1 before the first SOS (scan) or EOI (end) marker.
Scanning is limited to a 16 MiB header span, including SOI, segment headers and skipped payloads, and 4096
marker/fill steps after SOI. Non-APP1 payloads are skipped without loading them. Each APP1 payload must be
complete and is at most 65,533 bytes. Header extraction succeeds for the first complete
standard XMP packet within both limits, including a packet ending exactly at either limit; otherwise it
fails without a whole-file fallback, including on I/O errors or an incomplete APP1 whose available bytes
form parseable XMP. A valid JPEG with
XMP beyond those limits therefore cannot be manually reread through this path. Extended XMP assembly and
packets between scans are not supported by manual JPEG rereading.
Packet completeness is stricter than the shared best-effort JPEG extractor; it is not strict whole-JPEG
validation. A complete early XMP packet can be returned without examining the remaining file. Import and
`photo.allMetadata` retain their existing extraction paths. Command regressions in
`crates/engine/src/tests_xmp.rs` cover metadata/undo and sidecar precedence, an over-nested XMP packet and a
truncated APP1 without an XMP signature. Separate reader cases in `sidecar::jpeg_header_tests` use small
counted readers and injected limits for span/step boundaries, complete versus truncated APP1, early stopping,
short reads and I/O errors.
## Saving into an existing sidecar
A sidecar may already hold another application's data — e.g. its `crs:` develop settings and `xmpMM:History` — often