diff --git a/packages/common/src/config/spec.json b/packages/common/src/config/spec.json index 59db674..84f46cb 100644 --- a/packages/common/src/config/spec.json +++ b/packages/common/src/config/spec.json @@ -3046,11 +3046,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Passport should be taken in a Light Grey background as shown in this example. \nYou should print Finland Passport photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Passport photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_visa", @@ -3066,11 +3071,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Visa photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Visa photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Visa should be taken in a Light Grey background as shown in this example. \nYou should print Finland Visa photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Visa photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_passport_online", @@ -3080,18 +3090,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland Passport online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland Passport online should be 250 KB" + "comment": "The Finland Passport online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card_online", @@ -3101,18 +3116,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland ID card online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland ID card online should be 250 KB" + "comment": "The Finland ID card online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card", @@ -3128,11 +3148,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland ID card should be taken in a Light Grey background as shown in this example. \nYou should print Finland ID card photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland ID card photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "france_passport", diff --git a/packages/common/src/config/spec.test.ts b/packages/common/src/config/spec.test.ts index eb02dc8..dfc94e6 100644 --- a/packages/common/src/config/spec.test.ts +++ b/packages/common/src/config/spec.test.ts @@ -34,12 +34,26 @@ describe('spec.json', () => { 'minHeadHeight', 'maxHeadHeight', 'minHeadOffset', + 'maxHeadOffset', + 'minChinOffset', + 'maxChinOffset', + 'maxCenterOffset', ] as const) { const value = rules[key] if (value !== undefined && !(value > 0 && value <= 1)) { bad.push(`${spec.name}.${key}=${value}`) } } + for (const [min, max] of [ + ['minHeadOffset', 'maxHeadOffset'], + ['minChinOffset', 'maxChinOffset'], + ] as const) { + const lo = rules[min] + const hi = rules[max] + if (lo !== undefined && hi !== undefined && lo > hi) { + bad.push(`${spec.name}: ${min} > ${max}`) + } + } const { minHeadHeight, maxHeadHeight, minHeadOffset = 0 } = rules if ( minHeadHeight !== undefined && diff --git a/packages/common/src/locales/af.json b/packages/common/src/locales/af.json index 2139928..7ecb000 100644 --- a/packages/common/src/locales/af.json +++ b/packages/common/src/locales/af.json @@ -29,6 +29,8 @@ "printSheet": "4x6-duim-vel", "printSheetSaved": "4x6-vel: {count} x {title} ({size}), {columns}x{rows} teen {dpi} DPI.", "guides": "Riglyne", + "backgroundRemoval": "Agtergrondverwydering", + "backgroundRemovalHint": "Onder 100% skyn van die oorspronklike agtergrond deur, wat minder verwerk lyk. Owerhede kan dit steeds as 'n geredigeerde foto beskou.", "filter": "Filter", "filterOriginal": "Oorspronklik", "filterGrayscale": "S&W", diff --git a/packages/common/src/locales/ar.json b/packages/common/src/locales/ar.json index 8b0dfda..719c1c1 100644 --- a/packages/common/src/locales/ar.json +++ b/packages/common/src/locales/ar.json @@ -28,6 +28,8 @@ "printSheet": "ورقة 4x6 بوصة", "printSheetSaved": "ورقة 4x6: {count} × {title} ({size})، {columns}x{rows} بدقة {dpi} DPI.", "guides": "خطوط إرشادية", + "backgroundRemoval": "إزالة الخلفية", + "backgroundRemovalHint": "أقل من 100% يظهر جزء من الخلفية الأصلية، فتبدو الصورة أقل معالجة. قد تعتبرها الجهات الرسمية صورة معدّلة مع ذلك.", "filter": "فلتر", "filterOriginal": "الأصلي", "filterGrayscale": "أبيض وأسود", diff --git a/packages/common/src/locales/de.json b/packages/common/src/locales/de.json index 93c240c..32fcfc2 100644 --- a/packages/common/src/locales/de.json +++ b/packages/common/src/locales/de.json @@ -28,6 +28,8 @@ "printSheet": "4x6-Zoll-Bogen", "printSheetSaved": "4x6-Bogen: {count} x {title} ({size}), {columns}x{rows} bei {dpi} DPI.", "guides": "Hilfslinien", + "backgroundRemoval": "Hintergrundentfernung", + "backgroundRemovalHint": "Unter 100 % scheint etwas vom ursprünglichen Hintergrund durch, das wirkt weniger bearbeitet. Behörden können es trotzdem als bearbeitetes Foto werten.", "filter": "Filter", "filterOriginal": "Original", "filterGrayscale": "S/W", diff --git a/packages/common/src/locales/en.json b/packages/common/src/locales/en.json index c9aa6c7..e29733a 100644 --- a/packages/common/src/locales/en.json +++ b/packages/common/src/locales/en.json @@ -28,6 +28,8 @@ "printSheet": "4x6 inch sheet", "printSheetSaved": "4x6 sheet: {count} x {title} ({size}), {columns}x{rows} at {dpi} DPI.", "guides": "Guides", + "backgroundRemoval": "Background removal", + "backgroundRemovalHint": "Below 100% some of the original background shows through, which looks less processed. Authorities may still count it as an edited photo.", "filter": "Filter", "filterOriginal": "Original", "filterGrayscale": "B&W", diff --git a/packages/common/src/locales/fr.json b/packages/common/src/locales/fr.json index 91f7f4f..d89cf7a 100644 --- a/packages/common/src/locales/fr.json +++ b/packages/common/src/locales/fr.json @@ -28,6 +28,8 @@ "printSheet": "Planche 4x6 pouces", "printSheetSaved": "Planche 4x6 : {count} x {title} ({size}), {columns}x{rows} à {dpi} DPI.", "guides": "Repères", + "backgroundRemoval": "Suppression de l'arrière-plan", + "backgroundRemovalHint": "En dessous de 100 %, une partie de l'arrière-plan d'origine reste visible, ce qui paraît moins retouché. Les autorités peuvent quand même la considérer comme une photo modifiée.", "filter": "Filtre", "filterOriginal": "Original", "filterGrayscale": "N&B", diff --git a/packages/common/src/locales/ja.json b/packages/common/src/locales/ja.json index f95cf10..2977dff 100644 --- a/packages/common/src/locales/ja.json +++ b/packages/common/src/locales/ja.json @@ -28,6 +28,8 @@ "printSheet": "4x6インチ台紙", "printSheetSaved": "4x6台紙:{title}({size})× {count}枚、{columns}x{rows}、{dpi} DPI。", "guides": "ガイド", + "backgroundRemoval": "背景除去", + "backgroundRemovalHint": "100% 未満では元の背景が少し透けて、加工感が減ります。それでも当局は編集済みの写真とみなす場合があります。", "filter": "フィルター", "filterOriginal": "オリジナル", "filterGrayscale": "白黒", diff --git a/packages/common/src/locales/ko.json b/packages/common/src/locales/ko.json index cca387b..80844bb 100644 --- a/packages/common/src/locales/ko.json +++ b/packages/common/src/locales/ko.json @@ -28,6 +28,8 @@ "printSheet": "4x6인치 인화지", "printSheetSaved": "4x6 인화지: {title}({size}) {count}장, {columns}x{rows}, {dpi} DPI.", "guides": "안내선", + "backgroundRemoval": "배경 제거", + "backgroundRemovalHint": "100% 미만이면 원래 배경이 일부 비쳐 덜 가공된 느낌이 납니다. 그래도 기관에서는 편집된 사진으로 볼 수 있습니다.", "filter": "필터", "filterOriginal": "원본", "filterGrayscale": "흑백", diff --git a/packages/common/src/locales/pt.json b/packages/common/src/locales/pt.json index ea0abe4..5513411 100644 --- a/packages/common/src/locales/pt.json +++ b/packages/common/src/locales/pt.json @@ -28,6 +28,8 @@ "printSheet": "Folha de 4x6 polegadas", "printSheetSaved": "Folha 4x6: {count} x {title} ({size}), {columns}x{rows} a {dpi} DPI.", "guides": "Guias", + "backgroundRemoval": "Remoção do fundo", + "backgroundRemovalHint": "Abaixo de 100%, parte do fundo original aparece, o que parece menos processado. As autoridades ainda podem considerá-la uma foto editada.", "filter": "Filtro", "filterOriginal": "Original", "filterGrayscale": "P&B", diff --git a/packages/common/src/locales/ru.json b/packages/common/src/locales/ru.json index 0891e76..e6a883b 100644 --- a/packages/common/src/locales/ru.json +++ b/packages/common/src/locales/ru.json @@ -28,6 +28,8 @@ "printSheet": "Лист 4x6 дюйма", "printSheetSaved": "Лист 4x6: {count} x {title} ({size}), {columns}x{rows}, {dpi} DPI.", "guides": "Направляющие", + "backgroundRemoval": "Удаление фона", + "backgroundRemovalHint": "Ниже 100% часть исходного фона просвечивает, и фото выглядит менее обработанным. Органы всё равно могут счесть его отредактированным.", "filter": "Фильтр", "filterOriginal": "Оригинал", "filterGrayscale": "Ч/Б", diff --git a/packages/common/src/locales/th.json b/packages/common/src/locales/th.json index 921572a..409ac94 100644 --- a/packages/common/src/locales/th.json +++ b/packages/common/src/locales/th.json @@ -28,6 +28,8 @@ "printSheet": "แผ่นขนาด 4x6 นิ้ว", "printSheetSaved": "แผ่น 4x6: {title} ({size}) {count} รูป, {columns}x{rows} ที่ {dpi} DPI", "guides": "เส้นนำ", + "backgroundRemoval": "การลบพื้นหลัง", + "backgroundRemovalHint": "ต่ำกว่า 100% พื้นหลังเดิมจะปรากฏบางส่วน ทำให้ดูผ่านการแต่งน้อยลง แต่หน่วยงานอาจยังถือว่าเป็นภาพที่แก้ไขแล้ว", "filter": "ฟิลเตอร์", "filterOriginal": "ต้นฉบับ", "filterGrayscale": "ขาวดำ", diff --git a/packages/common/src/locales/tr.json b/packages/common/src/locales/tr.json index 90e65b3..2389fbe 100644 --- a/packages/common/src/locales/tr.json +++ b/packages/common/src/locales/tr.json @@ -28,6 +28,8 @@ "printSheet": "4x6 inç sayfa", "printSheetSaved": "4x6 sayfa: {count} x {title} ({size}), {columns}x{rows}, {dpi} DPI.", "guides": "Kılavuzlar", + "backgroundRemoval": "Arka plan kaldırma", + "backgroundRemovalHint": "%100'ün altında orijinal arka planın bir kısmı görünür ve fotoğraf daha az işlenmiş görünür. Yetkililer yine de bunu düzenlenmiş fotoğraf sayabilir.", "filter": "Filtre", "filterOriginal": "Orijinal", "filterGrayscale": "S/B", diff --git a/packages/common/src/locales/vi.json b/packages/common/src/locales/vi.json index a054f66..ab66638 100644 --- a/packages/common/src/locales/vi.json +++ b/packages/common/src/locales/vi.json @@ -28,6 +28,8 @@ "printSheet": "trang 4x6 inch", "printSheetSaved": "Trang 4x6: {count} x {title} ({size}), {columns}x{rows} ở {dpi} DPI.", "guides": "đường gióng", + "backgroundRemoval": "Xóa phông nền", + "backgroundRemovalHint": "Dưới 100%, một phần phông nền gốc sẽ hiện ra, trông ít qua xử lý hơn. Cơ quan chức năng vẫn có thể coi đây là ảnh đã chỉnh sửa.", "filter": "bộ lọc", "filterOriginal": "gốc", "filterGrayscale": "đen trắng", diff --git a/packages/common/src/locales/zh-HK.json b/packages/common/src/locales/zh-HK.json index 57a4b1f..dd57d02 100644 --- a/packages/common/src/locales/zh-HK.json +++ b/packages/common/src/locales/zh-HK.json @@ -28,6 +28,8 @@ "printSheet": "4x6 吋排版相片", "printSheetSaved": "4x6 排版:{count} 張 {title}({size}),{columns}x{rows},{dpi} DPI。", "guides": "參考線", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低於 100% 時會透出部分原有背景,看起來較少處理痕跡。但有關機構仍可能視為經編輯的相片。", "filter": "濾鏡", "filterOriginal": "原圖", "filterGrayscale": "黑白", diff --git a/packages/common/src/locales/zh-TW.json b/packages/common/src/locales/zh-TW.json index 05202a5..b4b47cf 100644 --- a/packages/common/src/locales/zh-TW.json +++ b/packages/common/src/locales/zh-TW.json @@ -28,6 +28,8 @@ "printSheet": "4x6 吋排版相片", "printSheetSaved": "4x6 排版:{count} 張 {title}({size}),{columns}x{rows},{dpi} DPI。", "guides": "參考線", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低於 100% 時會透出部分原始背景,看起來較少處理痕跡。但相關機關仍可能視為經過編輯的照片。", "filter": "濾鏡", "filterOriginal": "原圖", "filterGrayscale": "黑白", diff --git a/packages/common/src/locales/zh.json b/packages/common/src/locales/zh.json index 06a6de3..4542f1a 100644 --- a/packages/common/src/locales/zh.json +++ b/packages/common/src/locales/zh.json @@ -30,6 +30,8 @@ "printSheetTooLarge": "该规格尺寸过大,无法在4x6寸纸张上排版。", "printSheetFailed": "生成排版照片失败。", "guides": "参考线", + "backgroundRemoval": "背景移除", + "backgroundRemovalHint": "低于 100% 时会透出部分原始背景,看起来处理痕迹更少。但相关机构仍可能视为编辑过的照片。", "filter": "滤镜", "filterOriginal": "原图", "filterGrayscale": "黑白", diff --git a/packages/common/src/pages/cropper/index.tsx b/packages/common/src/pages/cropper/index.tsx index 9d8530a..6764fe6 100644 --- a/packages/common/src/pages/cropper/index.tsx +++ b/packages/common/src/pages/cropper/index.tsx @@ -22,7 +22,7 @@ import { useSegementStore, useSelectedSpec, } from '../../stores' -import { cropIDPhoto } from '../../utils' +import { cropIDPhoto, cropMatchingOriginal } from '../../utils' import { CloseButton, ConfigContext, @@ -109,15 +109,28 @@ export default function Cropper() { !result const sourceImage = useOriginal ? image : result - const idPhoto = await cropIDPhoto({ - image: sourceImage, + const cropOptions = { area: croppedArea, rotation, flipHorizontal, flipVertical, resolution: currentSpec.dimension, + } + const idPhoto = await cropIDPhoto({ + ...cropOptions, + image: sourceImage, }) - useAppStore.getState().retouch(idPhoto) + // The same crop of the original lets retouching blend some of the + // original background back in. The area was measured on the cutout, + // which may not share the original's resolution. + const original = useOriginal + ? undefined + : await cropMatchingOriginal({ + ...cropOptions, + image, + cutout: result, + }) + useAppStore.getState().retouch(idPhoto, original) } catch (error) { enqueueSnackbar( intl.formatMessage({ diff --git a/packages/common/src/pages/retouch/BackgroundColorPicker.tsx b/packages/common/src/pages/retouch/BackgroundColorPicker.tsx index fc9a6ab..058f260 100644 --- a/packages/common/src/pages/retouch/BackgroundColorPicker.tsx +++ b/packages/common/src/pages/retouch/BackgroundColorPicker.tsx @@ -1,16 +1,25 @@ import chroma from 'chroma-js' import { useCallback, useEffect, useState } from 'react' +import { useIntl } from 'react-intl' import Box from '@mui/material/Box' import Input from '@mui/material/Input' import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import { RangePicker } from '../../components' import { BACKGROUND_COLORS } from '../../config' import { useRetouchStore } from '../../stores' export function BackgroundColorPicker() { + const intl = useIntl() const [background, setBackground] = useRetouchStore((state) => [ state.background, state.setBackground, ]) + const [original, removal, setRemoval] = useRetouchStore((state) => [ + state.original, + state.removal, + state.setRemoval, + ]) const { color } = background const setColor = useCallback( (newColor: string) => { @@ -27,6 +36,34 @@ export function BackgroundColorPicker() { }, [color]) return ( + {original ? ( + + + {intl.formatMessage({ + id: 'backgroundRemoval', + defaultMessage: 'Background removal', + })} + + Math.round(value * 100)} + onChange={setRemoval} + /> + + {intl.formatMessage({ + id: 'backgroundRemovalHint', + defaultMessage: + 'Below 100% some of the original background shows through, which looks less processed. Authorities may still count it as an edited photo.', + })} + + + ) : null} ((set, get) => ({ useCropStore.getState().setImage(image) set({ step: 1 }) }, - retouch(image: string) { - useRetouchStore.getState().setImage(image) + retouch(image: string, original?: string) { + useRetouchStore.getState().setImage(image, original) set({ step: 2 }) }, cancel() { diff --git a/packages/common/src/stores/RetouchStore.ts b/packages/common/src/stores/RetouchStore.ts index 7c2de0a..fabde58 100644 --- a/packages/common/src/stores/RetouchStore.ts +++ b/packages/common/src/stores/RetouchStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { mixOriginalBackground } from '../utils/image' export type RetouchBackgroundGradientAngle = | 'l' @@ -68,7 +69,17 @@ export interface RetouchAdjustment { } export interface RetouchState { + /** The photo being retouched: the cutout, with `removal` applied. */ image: string + /** The cropped cutout, before any of the original is blended back. */ + cutout: string + /** The same crop of the original photo; empty when there is no cutout. */ + original: string + /** + * How much of the original background is removed, 0 to 1. Below 1 the + * original shows through at `1 - removal` opacity. + */ + removal: number background: RetouchBackground adjustment: RetouchAdjustment /** Show the head-position guides over the preview. */ @@ -76,7 +87,8 @@ export interface RetouchState { } export interface RetouchActions { - setImage(image: string): void + setImage(image: string, original?: string): void + setRemoval(removal: number): void setBackground(background: RetouchBackground): void setAdjustment(adjustment: RetouchAdjustment): void toggleGuides(): void @@ -87,6 +99,9 @@ export interface RetouchStore extends RetouchState, RetouchActions {} export const defaultRetouchState: RetouchState = { image: '', + cutout: '', + original: '', + removal: 1, background: { color: '#ffffff', gradient: { @@ -105,15 +120,71 @@ function revokeBlobURL(url: string) { } } +/** Every blob URL the store owns (the blended image is separate from the cutout). */ +function revokeAll({ image, cutout, original }: RetouchState) { + for (const url of new Set([image, cutout, original])) { + revokeBlobURL(url) + } +} + +/** Bumped per image and on reset, so a stale blend is thrown away. */ +let mixRun = 0 +/** Blending is slow on big photos: run one at a time, latest value wins. */ +let mixing = false + export const useRetouchStore = create((set, get) => ({ ...defaultRetouchState, - setImage(image: string) { - const previous = get().image - if (previous !== image) { - revokeBlobURL(previous) + setImage(image: string, original = '') { + const previous = get() + if (previous.image !== image || previous.original !== original) { + revokeAll(previous) } + mixRun++ // Keep the guide toggle across images; it is a view preference. - set({ ...defaultRetouchState, image, guides: get().guides }) + set({ + ...defaultRetouchState, + image, + cutout: image, + original, + guides: get().guides, + }) + }, + setRemoval(removal: number) { + set({ removal }) + if (mixing) { + return + } + const run = mixRun + mixing = true + const apply = async () => { + try { + // Re-read after each blend: the slider may have moved meanwhile. + for (;;) { + const { cutout, original, removal: wanted } = get() + const url = + original && wanted < 1 + ? await mixOriginalBackground(cutout, original, wanted) + : cutout + if (run !== mixRun) { + if (url !== cutout) revokeBlobURL(url) + return + } + const { image: previous } = get() + if (previous !== cutout && previous !== url) revokeBlobURL(previous) + set({ image: url }) + if (get().removal === wanted) return + } + } finally { + mixing = false + // A new image arrived mid-blend and its slider moved: blend it now. + if (run !== mixRun && get().original && get().removal < 1) { + get().setRemoval(get().removal) + } + } + } + apply().catch(() => { + // Keep the last good blend. + }) }, setBackground(background: RetouchBackground) { set({ background }) @@ -125,7 +196,8 @@ export const useRetouchStore = create((set, get) => ({ set({ guides: !get().guides }) }, reset() { - revokeBlobURL(get().image) + revokeAll(get()) + mixRun++ set(defaultRetouchState) }, })) diff --git a/packages/common/src/types/index.ts b/packages/common/src/types/index.ts index d06e68d..1e2b6da 100644 --- a/packages/common/src/types/index.ts +++ b/packages/common/src/types/index.ts @@ -29,6 +29,14 @@ export interface IDPhotoSpec { minHeadHeight?: number /** Clear space required above the crown, measured from the top edge. */ minHeadOffset?: number + /** Largest clear space allowed above the crown. */ + maxHeadOffset?: number + /** Clear space required below the chin, measured from the bottom edge. */ + minChinOffset?: number + /** Largest clear space allowed below the chin. */ + maxChinOffset?: number + /** How far the face's centre line may stray from the photo's centre line. */ + maxCenterOffset?: number /** Largest chin-to-crown head height. */ maxHeadHeight?: number /** Eye line distance from the BOTTOM edge, lower bound (min ≤ max). */ diff --git a/packages/common/src/utils/compliance.test.ts b/packages/common/src/utils/compliance.test.ts index f3896b0..e52f43c 100644 --- a/packages/common/src/utils/compliance.test.ts +++ b/packages/common/src/utils/compliance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + encodeWithinSize, getFileSizeExcess, hasGradientBackground, isNonNeutralAdjustment, @@ -61,3 +62,25 @@ describe('getFileSizeExcess', () => { expect(getFileSizeExcess(10, undefined)).toBeNull() }) }) + +describe('encodeWithinSize', () => { + // A fake encoder whose output grows with quality: 1000 bytes per 0.01. + const encode = async (quality: number) => + new Blob([new Uint8Array(Math.round(quality * 100_000))]) + + it('keeps the top quality when it already fits', async () => { + const blob = await encodeWithinSize(encode, 200_000) + expect(blob.size).toBe(95_000) + }) + + it('finds the highest quality under the limit', async () => { + const blob = await encodeWithinSize(encode, 70_000) + expect(blob.size).toBeLessThanOrEqual(70_000) + expect(blob.size).toBeGreaterThan(65_000) + }) + + it('returns the smallest file when nothing fits', async () => { + const blob = await encodeWithinSize(encode, 10_000) + expect(blob.size).toBe(50_000) + }) +}) diff --git a/packages/common/src/utils/compliance.ts b/packages/common/src/utils/compliance.ts index f5b268a..09d7812 100644 --- a/packages/common/src/utils/compliance.ts +++ b/packages/common/src/utils/compliance.ts @@ -46,3 +46,34 @@ export function getFileSizeExcess( } return { actualKB: Math.ceil(bytes / 1024), maxKB } } + +/** + * Encode at the highest quality whose output is at most `maxBytes`, by + * binary search over `minQuality..maxQuality`. `encode` turns a quality + * (0..1) into a file. If even `minQuality` is too large, that smallest file + * is returned and the caller can still warn about it. + */ +export async function encodeWithinSize( + encode: (quality: number) => Promise, + maxBytes: number, + { minQuality = 0.5, maxQuality = 0.95, steps = 6 } = {} +): Promise { + const best = await encode(maxQuality) + if (best.size <= maxBytes) { + return best + } + let lo = minQuality + let hi = maxQuality + let fit: Blob | null = null + for (let i = 0; i < steps; i++) { + const quality = (lo + hi) / 2 + const blob = await encode(quality) + if (blob.size <= maxBytes) { + fit = blob + lo = quality + } else { + hi = quality + } + } + return fit ?? (await encode(minQuality)) +} diff --git a/packages/common/src/utils/headGuide.ts b/packages/common/src/utils/headGuide.ts index 5f2472b..af5f0e0 100644 --- a/packages/common/src/utils/headGuide.ts +++ b/packages/common/src/utils/headGuide.ts @@ -9,11 +9,30 @@ function round(value: number) { return Math.round(value * 10) / 10 } +function range(min: number, max: number) { + return round(min) === round(max) + ? `${round(min)} mm` + : `${round(min)}-${round(max)} mm` +} + +/** + * Guide strokes are drawn twice, a solid black line under white dashes, so + * they read as alternating black and white segments on any photo. + */ +const DASH = '4,4' +const stroke = (shape: (attrs: string) => string, width = 1) => + shape(`fill="none" stroke="black" stroke-width="${width}"`) + + shape( + `fill="none" stroke="white" stroke-width="${width}" stroke-dasharray="${DASH}"` + ) + /** * An SVG overlay showing where the head must sit, from the same placement - * model automatic framing uses: the target crown line, the band the chin must - * land in, the eye band when the spec has one, a shaded clear-space strip at - * the top, and head outlines at the smallest and largest permitted heights. + * model automatic framing uses: the crown line (or the band the crown must + * land in), the band the chin must land in, the eye band when the spec has + * one, a shaded clear-space strip at the top, head outlines at the smallest + * and largest permitted heights, and the vertical centre line (with the + * allowed drift either side when the spec sets one). * Returned as a data URL for use as a background image. */ export function createHeadGuideSVG( @@ -26,7 +45,17 @@ export function createHeadGuideSVG( return '' } const { width, height } = size - const { minHead, maxHead, minEye, maxEye, crownOffset } = placement + const { + minHead, + maxHead, + minEye, + maxEye, + crownOffset, + maxCrownOffset, + minChin, + maxChin, + } = placement + const { photoHeightMm } = metrics const smallest = minHead || placement.headRatio const largest = maxHead || smallest // With an eye band, heads of every size are anchored on its middle; @@ -37,15 +66,25 @@ export function createHeadGuideSVG( eyeMid !== null ? eyeMid - EYE_IN_HEAD * head : placement.crown const y = (fraction: number) => fraction * height const cx = width / 2 - const ellipse = (head: number) => { - const top = y(crownFor(head)) - const h = head * height - return `` - } - const hline = (at: number, dash: string, opacity = 0.7) => - `` + const ellipse = (top: number, h: number) => + stroke( + (attrs) => + `` + ) + const hline = (at: number) => + stroke( + (attrs) => + `` + ) + const vline = (at: number, opacity = 1) => + stroke( + (attrs) => + `` + ) + const band = (top: number, bottom: number, fill: string) => + bottom > top + ? `` + : '' const font = Math.max(9, Math.min(13, height * 0.028)) const label = (at: number, text: string, anchor: 'start' | 'end') => @@ -53,11 +92,32 @@ export function createHeadGuideSVG( const hasRange = largest > smallest const headLabel = hasRange - ? `${round(metrics.minHeadMm)}-${round(metrics.maxHeadMm)} mm` + ? range(metrics.minHeadMm, metrics.maxHeadMm) : `min ${round(metrics.minHeadMm || metrics.maxHeadMm)} mm` - const crownY = y(placement.crown) - const chinMinY = y(crownFor(smallest) + smallest) - const chinMaxY = y(crownFor(largest) + largest) + + // Crown: the band between the smallest and largest clear space when the + // spec bounds both, otherwise the target line. + const hasCrownBand = crownOffset > 0 && maxCrownOffset > crownOffset + const crownTopY = y(hasCrownBand ? crownOffset : placement.crown) + const crownBottomY = y(hasCrownBand ? maxCrownOffset : placement.crown) + const crownLabel = hasCrownBand + ? `crown ${range(crownOffset * photoHeightMm, maxCrownOffset * photoHeightMm)} from top` + : 'crown' + + // Chin: the spec's own clear-space band below the chin when it has one, + // otherwise where the smallest and largest heads end. + const chinTop = maxChin || minChin + const chinBottom = minChin || maxChin + const chinTopY = chinTop + ? y(1 - chinTop) + : y(crownFor(smallest) + smallest) + const chinBottomY = chinBottom + ? y(1 - chinBottom) + : y(crownFor(largest) + largest) + const chinLabel = + minChin > 0 && maxChin > minChin + ? `chin ${range(minChin * photoHeightMm, maxChin * photoHeightMm)} from bottom · head ${headLabel}` + : `chin · head ${headLabel}` let eyeBand = '' if (minEye > 0 || maxEye > 0) { @@ -65,28 +125,43 @@ export function createHeadGuideSVG( const bottom = y(1 - (minEye || maxEye)) eyeBand = (bottom > top - ? `` - : hline(top, '2,3', 0.6)) + label(bottom - 4, 'eyes', 'end') + ? band(top, bottom, 'rgba(110,170,255,0.18)') + : hline(top)) + label(bottom - 4, 'eyes', 'end') } + // Head outlines. With crown or chin bands, the smallest head spans the + // inner edges of the bands and the largest the outer edges, so the outlines + // meet the marker lines; otherwise both hang from the anchored crown. + const hasChinBand = minChin > 0 || maxChin > 0 + const outlines = + hasCrownBand || hasChinBand + ? ellipse(crownBottomY, chinTopY - crownBottomY) + + (chinBottomY > chinTopY || crownBottomY > crownTopY + ? ellipse(crownTopY, chinBottomY - crownTopY) + : '') + : ellipse(y(crownFor(smallest)), smallest * height) + + (hasRange ? ellipse(y(crownFor(largest)), largest * height) : '') + + const centerOffset = spec.rules?.maxCenterOffset ?? 0 + const centerDrift = centerOffset > 0 ? y(centerOffset) : 0 + const svg = `` + - (crownOffset > 0 - ? `` - : '') + + (crownOffset > 0 ? band(0, y(crownOffset), 'rgba(0,0,0,0.22)') : '') + + (hasCrownBand ? band(crownTopY, crownBottomY, 'rgba(80,220,140,0.2)') : '') + eyeBand + - (hasRange - ? `` + band(chinTopY, chinBottomY, 'rgba(80,220,140,0.2)') + + outlines + + vline(cx) + + (centerDrift > 0 + ? vline(cx - centerDrift, 0.6) + vline(cx + centerDrift, 0.6) : '') + - ellipse(smallest) + - (hasRange ? ellipse(largest) : '') + - hline(crownY, '') + - hline(chinMinY, '4,3') + - (hasRange ? hline(chinMaxY, '4,3') : '') + - label(Math.max(font, crownY - 4), 'crown', 'start') + - label(chinMinY - 4, `chin · head ${headLabel}`, 'start') + + hline(crownTopY) + + (crownBottomY > crownTopY ? hline(crownBottomY) : '') + + hline(chinTopY) + + (chinBottomY > chinTopY ? hline(chinBottomY) : '') + + label(Math.max(font, crownTopY - 4), crownLabel, 'start') + + label(chinTopY - 4, chinLabel, 'start') + `` return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` diff --git a/packages/common/src/utils/image.ts b/packages/common/src/utils/image.ts index daf8ed6..417e587 100644 --- a/packages/common/src/utils/image.ts +++ b/packages/common/src/utils/image.ts @@ -6,6 +6,7 @@ import { renderGLFXAdjustment, } from './canvas' import { withPngDensity } from './png' +import { encodeWithinSize } from './compliance' import { CropArea, getCropOutputSize, getCropTransform } from './crop' export type { IDPhotoDimension } @@ -27,6 +28,7 @@ export async function cropIDPhoto({ flipHorizontal = false, flipVertical = false, resolution, + sourceSize, }: { image: string area: CropArea @@ -37,11 +39,21 @@ export async function cropIDPhoto({ width: number height: number } + /** + * Treat the image as if it were this size: for cropping one image with an + * area measured on another of the same shape but a different resolution. + */ + sourceSize?: { + width: number + height: number + } }): Promise { if (!(area.width > 0) || !(area.height > 0)) { throw new Error('Empty crop area') } const image = await loadImage(imageURL) + const imageWidth = sourceSize?.width || image.naturalWidth || image.width + const imageHeight = sourceSize?.height || image.naturalHeight || image.height // Supersample for the retouch step, but keep the canvas within the limits // mobile browsers can allocate. const output = getCropOutputSize(resolution) @@ -57,8 +69,8 @@ export async function cropIDPhoto({ // One transform straight from the source: no full-size rotated intermediate. ctx.setTransform( ...getCropTransform({ - imageWidth: image.naturalWidth || image.width, - imageHeight: image.naturalHeight || image.height, + imageWidth, + imageHeight, rotation, flipHorizontal, flipVertical, @@ -67,7 +79,7 @@ export async function cropIDPhoto({ outputHeight: output.height, }) ) - ctx.drawImage(image, 0, 0) + ctx.drawImage(image, 0, 0, imageWidth, imageHeight) return new Promise((resolve, reject) => { canvas.toBlob((file) => { // Release the backing store early; large canvases add up on iOS. @@ -82,6 +94,78 @@ export async function cropIDPhoto({ }) } +/** + * Crop the original photo to match a crop of its cutout, whose area was + * measured on the cutout. Background removal may return a different + * resolution, so the original is scaled to the cutout's size first. Returns + * undefined when the two do not have the same shape (e.g. one was rotated + * by its EXIF orientation and the other not), as they cannot be lined up. + */ +export async function cropMatchingOriginal({ + image: originalURL, + cutout: cutoutURL, + ...options +}: Omit[0], 'sourceSize'> & { + cutout: string +}): Promise { + const [original, cutout] = await Promise.all([ + loadImage(originalURL), + loadImage(cutoutURL), + ]) + const sourceSize = { + width: cutout.naturalWidth || cutout.width, + height: cutout.naturalHeight || cutout.height, + } + const originalAspect = + (original.naturalWidth || original.width) / + (original.naturalHeight || original.height) + if ( + Math.abs(sourceSize.width / sourceSize.height / originalAspect - 1) > 0.01 + ) { + return undefined + } + return cropIDPhoto({ ...options, image: originalURL, sourceSize }) +} + +/** + * Blend part of the original background back into a cutout: the original + * photo drawn at `1 - removal` opacity under the cutout. At 1 the result is + * the plain cutout, at 0 the original photo. Both images must be the same + * crop. Returns a PNG blob URL. + */ +export async function mixOriginalBackground( + cutoutURL: string, + originalURL: string, + removal: number +): Promise { + const [cutout, original] = await Promise.all([ + loadImage(cutoutURL), + loadImage(originalURL), + ]) + const canvas = document.createElement('canvas') + canvas.width = cutout.naturalWidth || cutout.width + canvas.height = cutout.naturalHeight || cutout.height + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('Canvas 2D context unavailable') + } + ctx.globalAlpha = Math.min(Math.max(1 - removal, 0), 1) + ctx.drawImage(original, 0, 0, canvas.width, canvas.height) + ctx.globalAlpha = 1 + ctx.drawImage(cutout, 0, 0, canvas.width, canvas.height) + return new Promise((resolve, reject) => { + canvas.toBlob((file) => { + canvas.width = 0 + canvas.height = 0 + if (file) { + resolve(URL.createObjectURL(file)) + } else { + reject(new Error('failed')) + } + }, 'image/png') + }) +} + /** Render one finished ID photo (background + adjustments) onto a canvas. */ export async function renderIDPhotoCanvas({ image: imageURL, @@ -124,23 +208,37 @@ export async function renderIDPhotoCanvas({ return canvas } -function canvasToPNG(canvas: HTMLCanvasElement): Promise { +function canvasToBlob( + canvas: HTMLCanvasElement, + type = 'image/png', + quality?: number +): Promise { return new Promise((resolve, reject) => { - canvas.toBlob((file) => { - if (file) { - resolve(file) - } else { - reject(new Error('failed')) - } - }, 'image/png') + canvas.toBlob( + (file) => { + if (file) { + resolve(file) + } else { + reject(new Error('failed')) + } + }, + type, + quality + ) }) } +/** + * Render the finished ID photo. With `maxKB` (a spec's upload limit) it is a + * JPEG at the highest quality that fits, as online submissions expect; + * otherwise a PNG carrying the print density. + */ export async function createIDPhoto({ image, background, adjustment, resolution, + maxKB, }: { image: string background: RetouchBackground @@ -150,6 +248,7 @@ export async function createIDPhoto({ height: number dpi?: number } + maxKB?: number }): Promise { const canvas = await renderIDPhotoCanvas({ image, @@ -160,6 +259,23 @@ export async function createIDPhoto({ if (!canvas) { return null } - const blob = await canvasToPNG(canvas) + if (maxKB && maxKB > 0) { + // JPEG has no transparency: put a transparent background on white. + const flat = document.createElement('canvas') + flat.width = canvas.width + flat.height = canvas.height + const ctx = flat.getContext('2d') + if (!ctx) { + return null + } + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, flat.width, flat.height) + ctx.drawImage(canvas, 0, 0) + return encodeWithinSize( + (quality) => canvasToBlob(flat, 'image/jpeg', quality), + maxKB * 1024 + ) + } + const blob = await canvasToBlob(canvas) return resolution.dpi ? withPngDensity(blob, resolution.dpi) : blob } diff --git a/packages/common/src/utils/placement.test.ts b/packages/common/src/utils/placement.test.ts index f6741f3..3ea44f0 100644 --- a/packages/common/src/utils/placement.test.ts +++ b/packages/common/src/utils/placement.test.ts @@ -35,6 +35,26 @@ describe('getHeadPlacement', () => { expect(p.chin).toBeLessThanOrEqual(1) }) + it.each(['finland_passport', 'finland_passport_online'])( + 'places the %s head inside the Finnish Police bands', + (name) => { + // Poliisin passikuvaohje 2020: head 32-36 mm, 4-6 mm above the crown, + // 7-9 mm below the chin, on a 47 mm tall photo (653 px online). + const spec = byName(name) + const H = name.endsWith('online') ? 653 : 47 + const mm = H / 47 + const p = getHeadPlacement(spec)! + const rules = getPlacementRules(spec) + expect(solveCrown(rules, p.headRatio).fits).toBe(true) + expect(p.headRatio * H).toBeGreaterThanOrEqual(32 * mm - 0.5) + expect(p.headRatio * H).toBeLessThanOrEqual(36 * mm + 0.5) + expect(p.crown * H).toBeGreaterThanOrEqual(4 * mm - 0.5) + expect(p.crown * H).toBeLessThanOrEqual(6 * mm + 0.5) + expect((1 - p.chin) * H).toBeGreaterThanOrEqual(7 * mm - 0.5) + expect((1 - p.chin) * H).toBeLessThanOrEqual(9 * mm + 0.5) + } + ) + it.each(all.filter((s) => getHeadPlacement(s)).map((s) => [s.name, s] as const))( '%s: meets every rule that can be met', (_name, spec) => { diff --git a/packages/common/src/utils/placement.ts b/packages/common/src/utils/placement.ts index a44df12..b26c54d 100644 --- a/packages/common/src/utils/placement.ts +++ b/packages/common/src/utils/placement.ts @@ -26,6 +26,11 @@ export interface PlacementRules { maxHead: number /** Minimum clear space above the crown. */ crownOffset: number + /** Maximum clear space above the crown. */ + maxCrownOffset: number + /** Clear space below the chin, above the bottom edge. */ + minChin: number + maxChin: number /** Eye line height above the bottom edge. */ minEye: number maxEye: number @@ -38,6 +43,9 @@ export function getPlacementRules( minHeadHeight = 0, maxHeadHeight = 0, minHeadOffset = 0, + maxHeadOffset = 0, + minChinOffset = 0, + maxChinOffset = 0, minEyeHeight = 0, maxEyeHeight = 0, } = spec.rules ?? {} @@ -46,6 +54,9 @@ export function getPlacementRules( minHead: positive(minHeadHeight), maxHead: positive(maxHeadHeight), crownOffset: positive(minHeadOffset), + maxCrownOffset: positive(maxHeadOffset), + minChin: positive(minChinOffset), + maxChin: positive(maxChinOffset), minEye: positive(minEyeHeight), maxEye: positive(maxEyeHeight), } @@ -70,27 +81,34 @@ export function getHeadRatioRange(rules: PlacementRules) { /** * The crown position for a head of height `headRatio` whose eye line sits * `eyeInHead` of the way down it. The eye line goes to the middle of the - * spec's eye band when it has both bounds; otherwise the crown goes on the - * required clear-space line, or a little above the middle of the free space. - * The result is then kept inside every rule that can be met: clear space - * above the crown, the eye band and the chin inside the photo. `fits` is - * false when they cannot all be met at this head height. + * spec's eye band when it has both bounds; otherwise the crown goes in the + * middle of its clear-space band, on the required clear-space line, or a + * little above the middle of the free space. The result is then kept inside + * every rule that can be met: clear space above the crown and below the chin, + * the eye band and the chin inside the photo. `fits` is false when they + * cannot all be met at this head height. */ export function solveCrown( rules: PlacementRules, headRatio: number, eyeInHead = EYE_IN_HEAD ) { - const { crownOffset, minEye, maxEye } = rules + const { crownOffset, maxCrownOffset, minChin, maxChin, minEye, maxEye } = + rules const eyeDrop = eyeInHead * headRatio let lo = crownOffset let hi = 1 - headRatio + if (maxCrownOffset > 0) hi = Math.min(hi, maxCrownOffset) + if (minChin > 0) hi = Math.min(hi, 1 - minChin - headRatio) + if (maxChin > 0) lo = Math.max(lo, 1 - maxChin - headRatio) if (minEye > 0) hi = Math.min(hi, 1 - minEye - eyeDrop) if (maxEye > 0) lo = Math.max(lo, 1 - maxEye - eyeDrop) let crown: number if (minEye > 0 && maxEye > 0) { crown = 1 - (minEye + maxEye) / 2 - eyeDrop + } else if (crownOffset > 0 && maxCrownOffset > 0) { + crown = (crownOffset + maxCrownOffset) / 2 } else if (crownOffset > 0) { crown = crownOffset } else { diff --git a/packages/mcp/dist/data/spec.json b/packages/mcp/dist/data/spec.json index 59db674..84f46cb 100644 --- a/packages/mcp/dist/data/spec.json +++ b/packages/mcp/dist/data/spec.json @@ -3046,11 +3046,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Passport should be taken in a Light Grey background as shown in this example. \nYou should print Finland Passport photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Passport photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_visa", @@ -3066,11 +3071,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Visa photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Visa photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland Visa should be taken in a Light Grey background as shown in this example. \nYou should print Finland Visa photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland Visa photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_passport_online", @@ -3080,18 +3090,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland Passport online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland Passport online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland Passport online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland Passport online should be 250 KB" + "comment": "The Finland Passport online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card_online", @@ -3101,18 +3116,23 @@ "zone": "FI", "flag": "🇫🇮", "dimension": { - "dpi": 600, + "dpi": 352.8, "width": 500, "height": 653, "unit": "mm" }, "rules": { "size": 250, - "minHeadHeight": 0.7457886676875957, - "minHeadOffset": 0.08575803981623277 + "minHeadHeight": 0.6814701378254211, + "maxHeadHeight": 0.7656967840735069, + "minHeadOffset": 0.08575803981623277, + "maxHeadOffset": 0.12863705972434916, + "minChinOffset": 0.14701378254211334, + "maxChinOffset": 0.18989280245022971, + "maxCenterOffset": 0.03215926493108729 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card online photo is 21.17x27.64 (in mm) or 2.12x2.76 (in cm) or 0.83x1.09 (in inches) or 500x653 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card online photo. The minimum head height should be 487pixel. Top of the head should be at a minimum of 56pixel from the top. \nPhoto for Finland ID card online should be taken in a Light Grey background as shown in this example. \nThe Maximum file size for Finland ID card online should be 250 KB" + "comment": "The Finland ID card online photo must be exactly 500x653 pixels (36x47 mm), JPEG, at most 250 KB.\nHead height from crown to chin (excluding hair) must be 445-500 px. Space above the crown must be 56-84 px and below the chin 96-124 px. The centre line of the face may be at most 21 px from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "finland_id_card", @@ -3128,11 +3148,16 @@ "unit": "mm" }, "rules": { - "minHeadHeight": 0.7446808510638298, - "minHeadOffset": 0.0851063829787234 + "minHeadHeight": 0.6808510638297872, + "maxHeadHeight": 0.7659574468085106, + "minHeadOffset": 0.0851063829787234, + "maxHeadOffset": 0.1276595744680851, + "minChinOffset": 0.14893617021276595, + "maxChinOffset": 0.19148936170212766, + "maxCenterOffset": 0.031914893617021274 }, "background": "#d3d3d3", - "comment": "The size (width x height) of Finland ID card photo is 36x47 (in mm) or 3.6x4.7 (in cm) or 1.42x1.85 (in inches) or 850x1110 (in pixels at 600DPI)\nCare should be taken to position the face within the photo as per the following requirements for Finland ID card photo. The minimum head height should be 35mm. Top of the head should be at a minimum of 4mm from the top. \nPhoto for Finland ID card should be taken in a Light Grey background as shown in this example. \nYou should print Finland ID card photo on Glossy/Matte type of photo paper" + "comment": "The size (width x height) of Finland ID card photo is 36x47 mm.\nHead height from crown to chin (excluding hair) must be 32-36 mm. Space above the crown must be 4-6 mm and below the chin 7-9 mm. The centre line of the face may be at most 1.5 mm from the centre line of the photo.\nThe background should be plain, light and neutral; light grey is best.\nSource: Poliisin passikuvaohje (Finnish Police photo guide, 2020), https://poliisi.fi/documents/25235045/31329600/Poliisin-passikuvaohje-2020-FI.pdf" }, { "name": "france_passport", diff --git a/packages/mcp/dist/index.js b/packages/mcp/dist/index.js index 016da25..9bfb8fc 100755 --- a/packages/mcp/dist/index.js +++ b/packages/mcp/dist/index.js @@ -393,6 +393,9 @@ function getPlacementRules(spec) { minHeadHeight = 0, maxHeadHeight = 0, minHeadOffset = 0, + maxHeadOffset = 0, + minChinOffset = 0, + maxChinOffset = 0, minEyeHeight = 0, maxEyeHeight = 0 } = spec.rules ?? {}; @@ -401,6 +404,9 @@ function getPlacementRules(spec) { minHead: positive(minHeadHeight), maxHead: positive(maxHeadHeight), crownOffset: positive(minHeadOffset), + maxCrownOffset: positive(maxHeadOffset), + minChin: positive(minChinOffset), + maxChin: positive(maxChinOffset), minEye: positive(minEyeHeight), maxEye: positive(maxEyeHeight) }; @@ -420,15 +426,20 @@ function getHeadRatioRange(rules) { return { target, max }; } function solveCrown(rules, headRatio, eyeInHead = EYE_IN_HEAD) { - const { crownOffset, minEye, maxEye } = rules; + const { crownOffset, maxCrownOffset, minChin, maxChin, minEye, maxEye } = rules; const eyeDrop = eyeInHead * headRatio; let lo = crownOffset; let hi = 1 - headRatio; + if (maxCrownOffset > 0) hi = Math.min(hi, maxCrownOffset); + if (minChin > 0) hi = Math.min(hi, 1 - minChin - headRatio); + if (maxChin > 0) lo = Math.max(lo, 1 - maxChin - headRatio); if (minEye > 0) hi = Math.min(hi, 1 - minEye - eyeDrop); if (maxEye > 0) lo = Math.max(lo, 1 - maxEye - eyeDrop); let crown; if (minEye > 0 && maxEye > 0) { crown = 1 - (minEye + maxEye) / 2 - eyeDrop; + } else if (crownOffset > 0 && maxCrownOffset > 0) { + crown = (crownOffset + maxCrownOffset) / 2; } else if (crownOffset > 0) { crown = crownOffset; } else {