From e94a022518957d448fe86a4bd2beba5971467f63 Mon Sep 17 00:00:00 2001 From: javierbyte Date: Wed, 2 Sep 2026 02:16:44 +0200 Subject: [PATCH 1/5] Better advanced section --- .gitignore | 1 + README.md | 4 + app/globals.css | 173 ++++- app/layout.tsx | 11 +- app/page.tsx | 234 +++++-- app/privacy/page.tsx | 224 +++++++ app/site-footer.tsx | 9 + app/support/page.tsx | 244 +++++++ lib/PINTR.ts | 224 ++----- lib/canvasDataToGrayscale.ts | 51 -- lib/coords-txt.ts | 55 ++ lib/draw.ts | 11 +- lib/pintr-core/README.md | 130 ++++ lib/pintr-core/checkpoint.ts | 189 ++++++ lib/pintr-core/createPintr.ts | 251 +++++++ lib/pintr-core/index.ts | 11 + lib/pintr-core/math.ts | 29 + lib/pintr-core/random.ts | 34 + lib/{ => pintr-core}/scan.ts | 11 +- lib/pintr-core/source.ts | 120 ++++ lib/pintr-core/types.ts | 42 ++ lib/pintr-core/utils/applyVignette.ts | 52 ++ lib/pintr-core/utils/extendRgbaImage.ts | 182 +++++ lib/pintr-core/utils/frameRgbaImage.ts | 90 +++ lib/pintr-core/utils/index.ts | 9 + lib/pintr-core/utils/preparePintrImage.ts | 60 ++ lib/pintr-core/utils/resizeRgbaImage.ts | 60 ++ lib/pintr-core/utils/shared.ts | 44 ++ lib/pintr-core/utils/types.ts | 5 + lib/preprocess.ts | 133 ++++ lib/renderCoords.ts | 34 +- lib/smooth-svg.ts | 2 +- lib/svg.ts | 4 +- main.ts | 596 ++++++++++++----- package.json | 21 +- pnpm-lock.yaml | 770 ++++++++++++++-------- public/fonts/Brutalita-Regular.woff2 | Bin 3408 -> 0 bytes tests/coords-txt.test.ts | 137 ++++ tests/pintr-core.test.ts | 475 +++++++++++++ tests/register.mjs | 26 + 40 files changed, 3992 insertions(+), 766 deletions(-) create mode 100644 app/privacy/page.tsx create mode 100644 app/site-footer.tsx create mode 100644 app/support/page.tsx delete mode 100644 lib/canvasDataToGrayscale.ts create mode 100644 lib/coords-txt.ts create mode 100644 lib/pintr-core/README.md create mode 100644 lib/pintr-core/checkpoint.ts create mode 100644 lib/pintr-core/createPintr.ts create mode 100644 lib/pintr-core/index.ts create mode 100644 lib/pintr-core/math.ts create mode 100644 lib/pintr-core/random.ts rename lib/{ => pintr-core}/scan.ts (70%) create mode 100644 lib/pintr-core/source.ts create mode 100644 lib/pintr-core/types.ts create mode 100644 lib/pintr-core/utils/applyVignette.ts create mode 100644 lib/pintr-core/utils/extendRgbaImage.ts create mode 100644 lib/pintr-core/utils/frameRgbaImage.ts create mode 100644 lib/pintr-core/utils/index.ts create mode 100644 lib/pintr-core/utils/preparePintrImage.ts create mode 100644 lib/pintr-core/utils/resizeRgbaImage.ts create mode 100644 lib/pintr-core/utils/shared.ts create mode 100644 lib/pintr-core/utils/types.ts create mode 100644 lib/preprocess.ts delete mode 100644 public/fonts/Brutalita-Regular.woff2 create mode 100644 tests/coords-txt.test.ts create mode 100644 tests/pintr-core.test.ts create mode 100644 tests/register.mjs diff --git a/.gitignore b/.gitignore index 844c980..e526b54 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ next-env.d.ts npm-debug.log* yarn-debug.log* yarn-error.log* +.plans \ No newline at end of file diff --git a/README.md b/README.md index 8ddabba..c267d89 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ illustrations from your pictures. All the processing is made locally, your images never leave your computer and you are free to use them as you please. +The platform-neutral, resumable generator is documented in +[PINTR core](lib/pintr-core/README.md). It is currently a local module; this +refactor does not create or publish a package. + [![pintr](public/thumbnail.jpg)](https://javier.xyz/pintr/) ## Examples diff --git a/app/globals.css b/app/globals.css index f3e2909..6454cad 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,17 @@ +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-Regular.woff2') format('woff2'); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-Medium.woff2') format('woff2'); + font-weight: 500; + font-display: swap; +} + body { touch-action: pan-y; margin: 0; @@ -34,8 +48,8 @@ a:focus { body, html, button { - font-family: var(--font-brutalita), Courier, monospace, sans-serif; - letter-spacing: -1px; + font-family: 'Brutalita', Courier, monospace, sans-serif; + letter-spacing: var(--letter-spacing); font-weight: 400; font-style: normal; font-size: 24px; @@ -71,13 +85,15 @@ button { appearance: none; -webkit-appearance: none; margin: 8px 16px; - padding: 8px 16px; + padding: 10px 16px 8px; border: 3px solid #000; display: block; background: #ecf0f1; white-space: nowrap; color: #000; - font-size: 1rem; + font-size: var(--font-size-small); + font-weight: var(--font-weight-small); + letter-spacing: var(--letter-spacing-small); } button:hover { cursor: pointer; @@ -123,6 +139,16 @@ body.-dragging::after { :root { --sizeh: calc(1080px / 2); --sizew: calc(1080px / 2); + + /* normal text */ + --font-size: 1rem; + --font-weight: 400; + --letter-spacing: -1%; + + /* small text: 4/5 of normal, medium weight */ + --font-size-small: 0.88rem; + --font-weight-small: 500; + --letter-spacing-small: normal; } .inline { @@ -174,6 +200,9 @@ canvas, #srcImg { max-width: calc(100vw - 32px); max-height: calc(100vw - 32px); + /* The box follows the inline aspect-ratio of whichever image is shown, which + is not the drawing's once an aspect ratio is picked. */ + height: auto; } canvas, @@ -245,6 +274,35 @@ input[type='range']:focus-visible { outline: 2px solid #3498db; outline-offset: 2px; } +select { + margin-left: 8px; + flex: 1; + width: 180px; + appearance: none; + -webkit-appearance: none; + background-color: #95a5a6; + /* `appearance: none` drops the native dropdown marker, so draw one back: a + solid black triangle, matching the square slider thumbs. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M0 0h10L5 6z' fill='%23000'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 10px 6px; + color: #000; + border: none; + border-radius: 0; + height: 32px; + padding: 0 26px 0 8px; + font: inherit; + font-size: var(--font-size-small); +} +select:hover { + cursor: pointer; +} +select:focus-visible { + outline: 2px solid #3498db; + outline-offset: 2px; +} + input[type='range'].toggle { width: 48px; } @@ -300,6 +358,42 @@ input[type='range'].toggle::-moz-range-thumb { margin: 16px; } +.vignette-options--container { + display: none; +} + +/* An open folder: the toggle row is the tab and the panel below is the sheet + hanging off it, both tinted a step darker than the page and outlined with + inset shadows rather than borders. The tab is pulled down over the sheet's + top line by a negative bottom margin, so nothing inside the tab's own box + moves and its label sits exactly where it does when the folder is closed. */ +.folder.-open { + --folder-tint: #dfe4e6; +} + +.folder.-open > .folder--tab { + position: relative; + z-index: 1; + width: fit-content; + margin-bottom: -1px; + background: var(--folder-tint); + /* left + top, then right — no bottom, so the tab opens into the sheet. */ + box-shadow: + inset 1px 1px #000, + inset -1px 0 #000; +} + +.folder.-open > .folder--sheet { + background: var(--folder-tint); + box-shadow: + inset 1px 1px #000, + inset -1px -1px #000; +} + +.srcimage-container { + cursor: pointer; +} + ul, ol, li, @@ -307,3 +401,74 @@ p { margin: 0; padding: 0; } + +html { + color-scheme: light; +} + +.doc-header h1 { + line-height: 1.2; + padding: 8px 0; +} +.doc-header .doc-back { + margin: 0 0 0 16px; + font-size: 0.7rem; +} + +.doc { + max-width: 42rem; + padding: 0 16px 32px; + line-height: 1.5; + font-size: 0.8rem; + overflow-wrap: anywhere; +} +.doc h2 { + font-weight: 400; + font-size: 1rem; + margin: 32px 0 8px; +} +.doc h3 { + font-weight: 400; + font-size: 0.85rem; + color: #34495e; + margin: 24px 0 4px; +} +.doc p, +.doc ul, +.doc ol { + margin: 0 0 12px; +} +.doc ul, +.doc ol { + padding-left: 1.4em; + list-style-position: outside; +} +.doc li { + margin-bottom: 4px; +} +.doc strong { + color: #34495e; +} +.doc .meta { + color: #555; + font-size: 0.7rem; +} + +/* A control whose option has nothing to act on yet — the extend toggle with no + padding and no aspect ratio change. */ +.input-container.-disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.input-container.-disabled input[type='range'] { + pointer-events: none; +} + +/* Explains a download's format next to its button, in the muted register the + instructions use. */ +.export-note { + margin: 8px 16px 8px 0; + color: #555; + line-height: 1.4; +} diff --git a/app/layout.tsx b/app/layout.tsx index f01aeb2..0f609cd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,7 @@ import type { Metadata, Viewport } from 'next'; -import localFont from 'next/font/local'; -import { GoogleAnalytics } from '@next/third-parties/google'; import './globals.css'; -const brutalita = localFont({ - src: '../public/fonts/Brutalita-Regular.woff2', - display: 'swap', - variable: '--font-brutalita', -}); - const TITLE = 'PINTR - Create plotter-like line drawings from your images'; const DESCRIPTION = 'Create plotter-like line drawings from your images.'; @@ -60,9 +52,8 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} - ); } diff --git a/app/page.tsx b/app/page.tsx index 79fb3ae..01eedce 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,27 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; +import { GoogleAnalytics } from '@next/third-parties/google'; -export default function Page() { - const started = useRef(false); +import SiteFooter from './site-footer'; +export default function Page() { useEffect(() => { - if (started.current) return; - started.current = true; - // The DOM is now mounted, so main.ts's top-level querySelector/listener - // wiring (and final startNewDrawing()) runs against the rendered elements. - import('../main'); + let disposed = false; + let cleanup: (() => void) | undefined; + + // Initialize after React has mounted the DOM. The module itself is cached by + // Next.js, so setup must run on every mount (including client-side back + // navigation from Privacy or Support). + import('../main').then(({ initializePintrApp }) => { + if (disposed) return; + cleanup = initializePintrApp(); + }); + + return () => { + disposed = true; + cleanup?.(); + }; }, []); return ( @@ -62,9 +73,10 @@ export default function Page() { type="range" data-start-drawing id="lines" + step="1" min="0" max="100" - defaultValue="50" + defaultValue="35" /> @@ -74,6 +86,7 @@ export default function Page() { type="range" data-start-drawing id="contrast" + step="10" min="0" max="100" defaultValue="50" @@ -86,9 +99,10 @@ export default function Page() { type="range" data-start-drawing id="definition" + step="10" min="0" max="100" - defaultValue="50" + defaultValue="55" /> @@ -111,43 +125,15 @@ export default function Page() { type="range" data-start-drawing id="strokeWidth" - step="0.25" - min="0.5" - max="5" - defaultValue="1.5" + step="1" + min="0" + max="100" + defaultValue="40" /> - -
- - -
- -
-
    -
  1. - Select an image — images with a face, white or light backgrounds - and good contrast work better. -
  2. -
  3. Play with the controls.
  4. -
  5. Download. Your pictures never leave your computer.
  6. -
-

- More information and source available on{' '} - Github. -

-
- - - -
- +
- + + +
+
+ +
+
+ + +
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+
+
Smoothing only applies to "single line" drawings.
+ +
+ +

+ x,y rows · · + origin top left +

+
+
+ +
+ + + + {/* Only affects the downloads, so it lives with the download buttons. */} +
+ + +
+ +
+
    +
  1. + Select an image — images with a face, white or light backgrounds + and good contrast work better. +
  2. +
  3. Play with the controls.
  4. +
  5. Download. Your pictures never leave your computer.
  6. +
+

+ More information and source available on{' '} + Github. +

+
+ + + ); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..9fc631d --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,224 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import SiteFooter from '../site-footer'; + +const TITLE = 'PINTR Privacy Policy'; +const DESCRIPTION = + 'How the PINTR app for iPhone and iPad, and this website, handle your data.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: 'https://javier.xyz/pintr/privacy', + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: 'https://javier.xyz/pintr/privacy', + siteName: 'PINTR', + locale: 'en_US', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + site: '@javierbyte', + creator: '@javierbyte', + }, +}; + +export default function PrivacyPage() { + return ( + <> + + Skip to content + +
+

PINTR Privacy Policy

+

+ Back to PINTR +

+
+ +
+

Last updated: August 29, 2026.

+ +

+ PINTR turns a photo into a line drawing. The app for iPhone and iPad + does all of that work on your device. It has no account, no server, + and nothing to sign in to, so there is no personal information for it + to collect, store, or share. +

+ +

+ This policy covers two separate things: the PINTR app{' '} + you install from the App Store, and this website at + javier.xyz/pintr. They are described separately below because they do + not work the same way. +

+ +

The PINTR app for iPhone and iPad

+ +

+ The app collects no data. Its bundled privacy + manifest declares no collected data types and no tracking, which is + why its App Store listing shows{' '} + Data Not Collected. +

+ +
    +
  • No account, sign-in, or profile.
  • +
  • No analytics, crash reporting, or usage measurement.
  • +
  • No advertising, and no tracking across apps or websites.
  • +
  • No third-party SDKs.
  • +
  • + No network requests. The drawing engine, the preview, and the + exports are all computed on your device, and the app keeps working + with no connection at all. +
  • +
  • Nothing is sold or shared with anyone.
  • +
+ +

Photos, camera, and files

+ +

+ The app can only see an image after you choose it yourself, through + the Add Image menu: +

+ +
    +
  • + Choose Photo opens the system photo picker. The + picker runs outside the app and hands over only the single item you + pick, so the app never gets access to your photo library. +
  • +
  • + Take Photo opens the camera. iOS asks for camera + permission the first time, for the reason shown in the prompt: + "Take a photo to turn it into a PINTR drawing." The photo + goes straight into the drawing and is not saved anywhere by PINTR. +
  • +
  • + Open File… and drag-and-drop read the one image + file you select. +
  • +
+ +

+ Whatever the source, the image is prepared and drawn entirely on your + device. It is never uploaded, and it is not copied anywhere outside + the app. Choosing another image, or closing the app, discards the + previous one. +

+ +

Exported drawings

+ +

+ When you export a drawing, PINTR writes a PNG or SVG file into + app-owned temporary storage on your device. That file leaves the app + only when you send it somewhere yourself with the system share sheet + — to Files, Photos, Mail, or another app of your choosing. PINTR does + not upload it, and has no say in what the destination app does with + it afterwards. +

+ +

+ Temporary export files are removed on a later launch of the app, so + save or share an export you want to keep rather than relying on that + temporary copy. +

+ +

Data retention and deletion

+ +

+ There is no server-side user data to retain or delete, because the + app sends nothing anywhere. Everything stays under your control on + your device: +

+ +
    +
  • + The image currently loaded in the app is discarded when you replace + it or close the app. +
  • +
  • + Exports you shared live in whichever app or folder you sent them to, + and are deleted there — in Files, Photos, or the destination app. +
  • +
  • + Temporary exports the app still holds are cleared on a later launch, + and deleting the app removes them along with everything else it + stores. +
  • +
  • + Camera permission can be changed or revoked at any time in Settings + → Privacy & Security → Camera, or in Settings → PINTR. +
  • +
+ +

This website

+ +

+ This section is about javier.xyz/pintr only. None of it applies to the + app, which contacts no servers at all. +

+ +
    +
  • + The browser version of PINTR draws your image locally in your + browser. Images are never uploaded to a server. +
  • +
  • + The site is hosted by Vercel, which + processes ordinary request data such as IP address and browser user + agent in order to deliver the pages and keep the service secure. +
  • +
  • + The drawing tool page uses Google Analytics (Google LLC) to count + visits. +
  • +
  • + + This privacy page and the support page load no analytics and set + no cookies. + +
  • +
  • + The typeface is served from this site, so no font or other + third-party CDN sees your visit. +
  • +
+ +

Children

+ +

+ PINTR is not directed at children, and because it collects no data it + collects nothing from children either. +

+ +

Changes to this policy

+ +

+ If the app or this site ever changes how it handles data, this page is + updated and the date at the top changes with it. This address stays + stable, because released versions of the app link to it. +

+ +

Contact

+ +

+ Privacy questions go to{' '} + pintr@javier.xyz. The app and + this site are made by Javier Bórquez ( + javier.xyz). See also the{' '} + support page. +

+
+ + + + ); +} diff --git a/app/site-footer.tsx b/app/site-footer.tsx new file mode 100644 index 0000000..b469b91 --- /dev/null +++ b/app/site-footer.tsx @@ -0,0 +1,9 @@ +export default function SiteFooter() { + return ( + + ); +} diff --git a/app/support/page.tsx b/app/support/page.tsx new file mode 100644 index 0000000..5b6802e --- /dev/null +++ b/app/support/page.tsx @@ -0,0 +1,244 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import SiteFooter from '../site-footer'; + +const TITLE = 'PINTR Support'; +const DESCRIPTION = + 'Help, contact, and troubleshooting for the PINTR app for iPhone and iPad.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: 'https://javier.xyz/pintr/support', + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: 'https://javier.xyz/pintr/support', + siteName: 'PINTR', + locale: 'en_US', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + site: '@javierbyte', + creator: '@javierbyte', + }, +}; + +export default function SupportPage() { + return ( + <> + + Skip to content + +
+

PINTR Support

+

+ Back to PINTR +

+
+ +
+

Last updated: August 29, 2026.

+ +

+ PINTR turns a photo into a plotter-like line drawing on your iPhone or + iPad. Everything runs on the device. Read the{' '} + + privacy policy + {' '} + for how it handles your photos and exports. +

+ +

Contact

+ +

+ Email pintr@javier.xyz with + questions, bugs, or feature requests. It helps to include your device + model, your iOS or iPadOS version, and the app version shown at the + bottom of About PINTR inside the app. +

+ +

Requirements

+ +
    +
  • iPhone or iPad running iOS or iPadOS 26 or later.
  • +
  • + No account and no internet connection. After installation PINTR + works entirely offline. +
  • +
+ +

Getting started

+ +
    +
  1. + Tap Add Image and pick a source:{' '} + Choose Photo for the photo library,{' '} + Take Photo for the camera, or{' '} + Open File… for an image file. On iPad you can also + drag an image into the app. +
  2. +
  3. + The drawing starts immediately and fills in line by line while you + watch. +
  4. +
  5. + Adjust the controls. Every change redraws right away. +
  6. +
  7. + Tap Export to share the result as PNG or SVG. +
  8. +
+ +

+ Photos with a clear subject, good contrast, and a light background + give the strongest drawings. +

+ +

Controls

+ +
    +
  • + Lines — how many strokes the drawing uses. More + lines mean more detail and a longer draw. +
  • +
  • + Contrast — how strongly light and dark areas are + separated before drawing. +
  • +
  • + Definition — how closely the strokes follow fine + detail rather than broad shapes. +
  • +
  • + Stroke width — the thickness of each line. +
  • +
  • + Single continuous line — draws the whole image + without lifting the pen, the classic plotter look. +
  • +
  • + New variation — picks a new seed, giving a + different drawing of the same photo. The current seed is shown next + to it. +
  • +
+ +

+ Pinch or use a trackpad to zoom, drag to pan, and double-tap to reset + the view. Fit Artwork returns the drawing to the + screen if you lose it while zoomed in. +

+ +

Exporting PNG and SVG

+ +
    +
  • + Share PNG exports the drawing as an image, at the + size of the prepared artwork. +
  • +
  • + Share SVG exports vector paths, for plotters, + cutters, and editors like Illustrator, Affinity, Figma, or Inkscape. +
  • +
  • + Both open the system share sheet, from which you can save to Files + or Photos, send the file, or open it in another app. +
  • +
  • + Exports are written to temporary storage inside the app and are + cleared on a later launch, so save or send the file rather than + relying on that temporary copy. +
  • +
+ +

Permissions

+ +

+ Photos and files. Choosing a photo or opening a file + goes through the system picker, which hands PINTR only the item you + selected. No separate permission is requested, and PINTR never gets + access to your whole library. +

+ +

+ Camera. The first time you use{' '} + Take Photo, iOS asks for camera permission. If you + declined earlier and want it back, open Settings → PINTR and turn on + Camera, or go to Settings → Privacy & Security → Camera and enable + PINTR there. Return to the app and choose{' '} + Take Photo again. Everything else in the app keeps + working without camera access. +

+ +

Troubleshooting

+ +

"The selected item is not a readable image."

+

+ The file is not an image PINTR can open, or it is not an image at all + — a PDF, a video, or a document with an image-like name. Try a JPEG, + PNG, or HEIC file instead. +

+ +

"PINTR could not prepare the selected image."

+

+ The file is an image, but its contents could not be decoded — usually + a partially downloaded or corrupted file, or an unusual camera raw + format. Open it in Photos first, export or duplicate it as a JPEG, and + import that copy. +

+ +

The drawing looks blank, faint, or too dark

+

+ Raise Lines for more coverage and adjust{' '} + Contrast. Photos that are very dark, very flat, or + busy from edge to edge give the engine little to follow; a portrait or + a subject against a light background works far better. +

+ +

Drawing feels slow

+

+ A high line count is simply a lot of work. The image appears + progressively and stays interactive while it draws; lower{' '} + Lines for a faster result, and use{' '} + Stop to end a draw you no longer want. +

+ +

An export failed

+

+ Free up storage space on the device and try again. If it keeps + failing, note what you were exporting and email{' '} + pintr@javier.xyz. +

+ +

Privacy

+ +

+ Your photos and drawings stay on your device. PINTR has no account, no + analytics, and no network service. The full{' '} + + privacy policy + {' '} + explains what that means for Photos, the camera, files, and exports. +

+ +

PINTR in a browser

+ +

+ There is also a free web version of PINTR at{' '} + javier.xyz/pintr, and the drawing engine is open + source on{' '} + GitHub. +

+
+ + + + ); +} diff --git a/lib/PINTR.ts b/lib/PINTR.ts index 900fc4f..ebb7941 100644 --- a/lib/PINTR.ts +++ b/lib/PINTR.ts @@ -1,32 +1,34 @@ -import { imageToRawData, ctxToRGBGrayscaleMatrix } from 'canvas-image-utils'; +import { imageToRawData } from 'canvas-image-utils'; import Draw from './draw'; -import { canvasDataToGrayscale } from './canvasDataToGrayscale'; -import { scanLine } from './scan'; -import { intRnd, tweenValue } from './utils'; +import { createPintr } from './pintr-core'; +import type { + PintrConfig as CoreConfig, + PintrLine, + PintrSession, +} from './pintr-core'; +import { preparePintrImage } from './pintr-core/utils'; const DRAW_SIZE = 1080; export type Coord = [number, number]; - -export type PintrConfig = { - contrast: number; - definition: number; - singleLine: boolean; - strokeWidth: number; -}; +export type PintrConfig = CoreConfig; type Session = { - singleLine: boolean; - tweenDefinition: number; - updateSampleRate: number; - plusColor: string; - minusColor: string; + pintr: PintrSession; strokeWidth: number; target: number; - drawnCount: number; }; +function toCoords(line: PintrLine): [Coord, Coord] { + return [ + [line[0][0], line[0][1]], + [line[1][0], line[1][1]], + ]; +} + +// Browser compatibility around the platform-neutral generator. The app still +// owns image URLs, frame pacing, and visible Canvas strokes; core only picks lines. export async function pinterCreator( imgSrc: string, { @@ -45,15 +47,7 @@ export async function pinterCreator( }) => void; } ) { - // Per-instance drawing state, scoped to this closure so two instances never - // collide (a superseded one may still be resolving a requestAnimationFrame). const canvasSrc = document.createElement('canvas'); - let S: Uint8Array[] = []; - let COORDS: [Coord, Coord][] = []; - let cursor: Coord = [0, 0]; - let running = false; - let stopped = false; - const data = await imageToRawData(imgSrc, { size: DRAW_SIZE, canvas: canvasSrc, @@ -61,202 +55,92 @@ export async function pinterCreator( }); const WIDTH = data.width; const HEIGHT = data.height; - - canvasSrc.width = WIDTH; - canvasSrc.height = HEIGHT; - const srcCtx: CanvasRenderingContext2D | null = canvasSrc.getContext('2d', { - willReadFrequently: true, + const image = preparePintrImage({ + width: WIDTH, + height: HEIGHT, + rgba: data.data, }); - if (!srcCtx) { - throw new Error("Failed to initiate 'CanvasRenderingContext2D'"); - } - - let pencilSrc = Draw(srcCtx); - const canvasDraw = canvasDrawEl || document.createElement('canvas'); canvasDraw.width = WIDTH; canvasDraw.height = HEIGHT; - const srcImgEl: HTMLImageElement | null = document.querySelector('#srcImg'); const drawCtx: CanvasRenderingContext2D | null = canvasDraw.getContext('2d'); - if (!srcImgEl) { - throw new Error('Failed to initiate srcImgEl'); - } - if (!drawCtx) { - throw new Error('Failed to initiate CanvasRenderingContext2D'); - } + if (!drawCtx) throw new Error('Failed to initiate CanvasRenderingContext2D'); - // Non-null aliases: the narrowing above doesn't carry into the nested closures. - const srcContext: CanvasRenderingContext2D = srcCtx; const drawContext: CanvasRenderingContext2D = drawCtx; + onLoad({ width: WIDTH, height: HEIGHT }); - srcImgEl.style.aspectRatio = String(WIDTH / HEIGHT); - - let pencilDraw = Draw(drawCtx); - onLoad({ - width: WIDTH, - height: HEIGHT, - }); - - const { canvasData } = canvasDataToGrayscale(data); - + let pencilDraw = Draw(drawContext); let session: Session | null = null; + let running = false; + let stopped = false; - function drawSequenceLine(currentSession: Session) { - let from = cursor; - - // if no `singleLine` hop to different points to find a new cursor - let toFrom = currentSession.singleLine ? 0 : currentSession.tweenDefinition; - while (toFrom--) { - let tmpFrom: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - if (S[from[0]][from[1]] > S[tmpFrom[0]][tmpFrom[1]]) { - from = tmpFrom; - } - } - - // now we look at different places to expand - let remainingCursorsToExplore = intRnd( - currentSession.tweenDefinition, - currentSession.tweenDefinition * 2 - ); - let to: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - let light = 255; - while (remainingCursorsToExplore--) { - let tmpTo: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - const tmpLight = scanLine(from, tmpTo, S); - if (tmpLight <= light) { - light = tmpLight; - to = tmpTo; - } - } - light = scanLine(from, to, S); - - COORDS.push([from, to]); - - pencilDraw.lineBuffer(from, to); - pencilSrc.lineBuffer(from, to); - cursor = to; - } - - // Draws lines toward `session.target` within a single ~15ms time budget, then - // resolves on the next animation frame so the canvas can paint between batches. + // Draws toward the current target for one browser frame budget. Requesting + // one core line at a time keeps the target exact even when Definition is slow. function drawBatch(currentSession: Session) { return new Promise((resolve) => { const time = Date.now(); - const startCount = currentSession.drawnCount; + const coords: [Coord, Coord][] = []; while ( Date.now() < time + 15 && - currentSession.drawnCount < currentSession.target + currentSession.pintr.lineCount < currentSession.target ) { - // here we put the changes back to the src and update our matrix - if (currentSession.drawnCount % currentSession.updateSampleRate === 0) { - pencilSrc.stroke({ - color: currentSession.minusColor, - width: currentSession.strokeWidth * 1.5, - }); - S = ctxToRGBGrayscaleMatrix(srcContext); - } - drawSequenceLine(currentSession); - currentSession.drawnCount++; + const line = currentSession.pintr.next(1).lines[0]; + const coord = toCoords(line); + coords.push(coord); + pencilDraw.lineBuffer(coord[0], coord[1]); } pencilDraw.stroke({ - color: currentSession.plusColor, - width: currentSession.strokeWidth * 1, + color: 'rgba(0, 0, 0, 255)', + width: currentSession.strokeWidth, }); - const newCoords = COORDS.slice(startCount, currentSession.drawnCount); - const done = currentSession.drawnCount >= currentSession.target; - onProgress && onProgress({ coords: newCoords, done }); + const done = currentSession.pintr.lineCount >= currentSession.target; + if (coords.length || done) onProgress && onProgress({ coords, done }); window.requestAnimationFrame(() => resolve()); }); } - // Keeps batching until the drawn count reaches the current target, then idles; - // raising the target via `requestLines` restarts it. The `running` flag keeps - // a single loop alive, and it reads the live `session` each iteration so a - // `start()` that swaps the session mid-flight is picked up once a batch resolves. + // A live session can be replaced while the previous batch is waiting for its + // animation frame. Reading `session` again each loop picks up the replacement. async function pump() { if (running) return; running = true; - while (!stopped && session && session.drawnCount < session.target) { + while ( + !stopped && + session && + session.pintr.lineCount < session.target + ) { await drawBatch(session); } running = false; } - // (Re)initialise a drawing session. Resets all drawing state and captures the - // config-derived values, but draws nothing until `requestLines` is called. function start(config: PintrConfig) { - const { singleLine, contrast, definition, strokeWidth } = config; - - COORDS = []; - - const tweenDefinition = Math.round( - tweenValue(definition, [ - [0, 3], - [50, 15], - [100, 75], - ]) - ); - - const plusColor = `rgba(0, 0, 0, 255)`; - const minusColor = `rgba(255, 255, 255, ${ - (100 - - Math.round( - tweenValue(contrast, [ - [0, 20], - [50, 67], - [100, 90], - ]) - )) / - 100 - })`; - - const updateSampleRate = 100 - Math.floor(tweenDefinition / 2); - - // Clear the canvas and start a fresh pencil so no partial sub-path from a - // previous session on this instance carries over. drawContext.clearRect(0, 0, WIDTH, HEIGHT); - pencilDraw = Draw(drawContext); - - srcContext.putImageData(canvasData, 0, 0); - pencilSrc = Draw(srcContext); - S = ctxToRGBGrayscaleMatrix(srcContext); - - cursor = [Math.floor(WIDTH / 2), Math.floor(HEIGHT / 2)]; - + pencilDraw = Draw(drawContext, config.singleLine); session = { - singleLine, - tweenDefinition, - updateSampleRate, - plusColor, - minusColor, - strokeWidth, + pintr: createPintr({ image, config }), + strokeWidth: config.strokeWidth, target: 0, - drawnCount: 0, }; } - // Set the target line count for the current session. Returns immediately; the - // pump loop catches up and streams the new lines via `onProgress`. Raising the - // target draws more (continuing from the last line); lowering it below the - // drawn count simply halts the loop — already-drawn lines are kept (the drawn - // count never decreases) so raising it again resumes instantly. function requestLines(targetCount: number) { - if (!session) { - throw new Error('PINTR: call start() before requestLines()'); + if (!session) throw new Error('PINTR: call start() before requestLines()'); + if (!Number.isInteger(targetCount) || targetCount < 0) { + throw new Error('PINTR: target line count must be a non-negative integer'); } + session.target = targetCount; if (!running) pump(); } - // Permanently halt this instance so its pump stops drawing — called when a new - // image takes over the canvas. function stop() { stopped = true; } diff --git a/lib/canvasDataToGrayscale.ts b/lib/canvasDataToGrayscale.ts deleted file mode 100644 index 74cd8b2..0000000 --- a/lib/canvasDataToGrayscale.ts +++ /dev/null @@ -1,51 +0,0 @@ -const RED_PREFERENCE = 0.1; -const WEIGHTS = { - r: 0.299 + RED_PREFERENCE, - g: 0.587 + RED_PREFERENCE * -0.5, - b: 0.114 + RED_PREFERENCE * -0.5, -}; - -export function canvasDataToGrayscale(canvasData: ImageData): { - canvasData: ImageData; - averageLightness: number; -} { - let pixels = canvasData.data; - - let minLightness = Infinity; - let maxLightness = 0; - let averageLightness = 0; - - for (let i = 0; i < pixels.length; i += 4) { - let lightness = - pixels[i] * WEIGHTS.r + - pixels[i + 1] * WEIGHTS.g + - pixels[i + 2] * WEIGHTS.b; - - if (pixels[i + 3] < 128) { - lightness = 255; - } - - minLightness = Math.min(minLightness, lightness); - maxLightness = Math.max(maxLightness, lightness); - averageLightness += lightness; - } - averageLightness = Math.round(averageLightness / (pixels.length / 4)); - - minLightness += 32; - maxLightness -= 32; - - const contrast = 255 / (maxLightness - minLightness); - - for (let i = 0; i < pixels.length; i += 4) { - let lightness = - pixels[i] * WEIGHTS.r + - pixels[i + 1] * WEIGHTS.g + - pixels[i + 2] * WEIGHTS.b; - - pixels[i] = Math.round(lightness * contrast) - minLightness; - pixels[i + 1] = Math.round(lightness * contrast) - minLightness; - pixels[i + 2] = Math.round(lightness * contrast) - minLightness; - } - - return { canvasData, averageLightness }; -} diff --git a/lib/coords-txt.ts b/lib/coords-txt.ts new file mode 100644 index 0000000..2cbd7c5 --- /dev/null +++ b/lib/coords-txt.ts @@ -0,0 +1,55 @@ +import type { Coord } from './PINTR'; + +// Plain-text geometry export. The other two exports are pictures; this one is +// the raw path, so it can be converted to G-code, HPGL, or whatever else the +// user's machine wants without parsing an SVG. +// +// The file is nothing but coordinates — no header, no comments — so it drops +// straight into any parser. The one thing a reader cannot infer from the rows, +// the canvas the coordinates are measured against, rides in the filename. + +function samePoint(a: Coord, b: Coord): boolean { + return a[0] === b[0] && a[1] === b[1]; +} + +/** + * Segments arrive as independent pairs, but single-line mode emits a chain + * where each segment starts exactly where the previous one ended. Splitting on + * that discontinuity recovers the connected paths without needing to know which + * mode produced them: a chain collapses to one path, disjoint segments stay as + * one two-point path each. + */ +export function segmentsToPaths(coords: [Coord, Coord][]): Coord[][] { + const paths: Coord[][] = []; + let current: Coord[] | null = null; + let previousEnd: Coord | null = null; + + for (const [from, to] of coords) { + if (!current || !previousEnd || !samePoint(from, previousEnd)) { + current = [from]; + paths.push(current); + } + current.push(to); + previousEnd = to; + } + + return paths; +} + +/** `PINTR-1080x1350.txt` — the pixel space the coordinates are measured in. */ +export function coordsFileName(size: [number, number]) { + return `PINTR-${size[0]}x${size[1]}.txt`; +} + +/** + * One `x,y` per row; a blank line starts a new path (pen up, travel, pen down). + * Unlike the SVG renderers, which take only the start of each pair and so drop + * the drawing's final endpoint, every generated point is written out. + */ +export function generateCoordsTxt(coords: [Coord, Coord][]) { + const body = segmentsToPaths(coords) + .map((path) => path.map((point) => `${point[0]},${point[1]}`).join('\n')) + .join('\n\n'); + + return body ? `${body}\n` : ''; +} diff --git a/lib/draw.ts b/lib/draw.ts index 532356d..338e7ba 100644 --- a/lib/draw.ts +++ b/lib/draw.ts @@ -1,9 +1,14 @@ -export default function Draw(ctx: CanvasRenderingContext2D) { +export default function Draw( + ctx: CanvasRenderingContext2D, + singleLine = false +) { ctx.beginPath(); + let hasPoint = false; function lineBuffer(from: [number, number], to: [number, number]) { - ctx.moveTo(from[0], from[1]); + if (!singleLine || !hasPoint) ctx.moveTo(from[0], from[1]); ctx.lineTo(to[0], to[1]); + hasPoint = true; } function stroke( @@ -12,9 +17,11 @@ export default function Draw(ctx: CanvasRenderingContext2D) { const { color = '#000', width = 1 } = ops; ctx.lineWidth = width; + ctx.lineJoin = 'round'; ctx.strokeStyle = color; ctx.stroke(); ctx.beginPath(); + hasPoint = false; } return { diff --git a/lib/pintr-core/README.md b/lib/pintr-core/README.md new file mode 100644 index 0000000..1458778 --- /dev/null +++ b/lib/pintr-core/README.md @@ -0,0 +1,130 @@ +# PINTR core + +```ts +import { createPintr, restorePintr } from './lib/pintr-core'; +import { preparePintrImage } from './lib/pintr-core/utils'; + +const image = preparePintrImage({ width, height, rgba }); +const pintr = createPintr({ + image, + config: { + contrast: 50, + definition: 50, + singleLine: true, + strokeWidth: 1.5, + }, + seed: 1234, +}); + +const first = pintr.next(128); +draw(first.lines); + +// Nothing runs between calls. Calling next() continues at line 128. +const second = pintr.next(128); +draw(second.lines); + +// A checkpoint contains the image's current working state and all prior lines. +const checkpoint = pintr.save(); +const restored = restorePintr(checkpoint); +draw(restored.next(128).lines); +``` + +The implementation is a local module for now; no package is created by this +extraction. A future package can expose the same two entry points as +`pintr-core` and `pintr-core/utils` without changing the API. + +## Core API + +The core receives prepared grayscale pixels and returns coordinates. It has no +DOM, Canvas, decoder, renderer, timer, or platform dependency. + +```ts +createPintr({ image, config, seed? }): PintrSession; +restorePintr(checkpoint): PintrSession; + +session.next(count): PintrBatch; +session.getLines(from?, to?): PintrLine[]; +session.save(): Uint8Array; +``` + +`next(count)` synchronously generates exactly that many lines. The session is +naturally paused as soon as the call returns; call it again whenever the host is +ready. Batch boundaries do not affect the generated coordinates. + +The input image is: + +```ts +type PintrImage = { + width: number; + height: number; + // Column-major: gray[x * height + y], where 0 is black and 255 is white. + gray: Uint8Array; +}; +``` + +PINTR copies the working pixels, so a prepared image can be reused to create +independent sessions. Supplying a uint32 `seed` makes a run reproducible. + +## Host scheduling + +A browser or React Native host should request small batches from its own frame +loop: + +```ts +function frame() { + const remaining = targetLineCount - pintr.lineCount; + if (remaining <= 0) return; + + draw(pintr.next(Math.min(32, remaining)).lines); + requestAnimationFrame(frame); +} +``` + +A CLI can use larger batches or generate its whole target in one call: + +```ts +while (pintr.lineCount < targetLineCount) { + pintr.next(Math.min(1000, targetLineCount - pintr.lineCount)); +} + +writeCoordinates(pintr.getLines()); +``` + +Saving is also synchronous. The returned, versioned binary checkpoint is +self-contained, so React Native or a CLI can persist it and later restore the +session without decoding the original image again. + +## Optional image utilities + +`./lib/pintr-core/utils` contains pure, non-mutating helpers that are not needed +by the generator itself: + +```ts +preparePintrImage(rgbaImage): PintrImage; +resizeRgbaImage(rgbaImage, { longestSide, allowUpscale? }): RgbaImage; +applyVignette(rgbaImage, options): RgbaImage; +frameRgbaImage(rgbaImage, { aspectRatio, padding, extend? }): RgbaImage; +extendRgbaImage(rgbaImage, { canvasWidth, canvasHeight, offsetX, offsetY }): RgbaImage; +``` + +`frameRgbaImage` places the image, contained, on an opaque white canvas of the +requested aspect ratio (`width / height`, or `null` to keep the source's). +`padding` is a percentage of the canvas short side and insets the picture within +that canvas, so the requested ratio always decides the output dimensions. With +`extend`, the space around the picture is filled by `extendRgbaImage` instead of +staying white. + +`extendRgbaImage` continues a picture outward over the canvas around it. Each +pixel outside averages the picture pixels within its own distance of the nearest +picture pixel, weighted so the nearest ones count for most. The fill leaves the +edge sharp, grows blurrier the further it travels, and settles on the picture's +overall tone far away from it — a diffusion outward rather than the edge pixel +smearing. The weight comes from averaging four nested boxes, each half the radius +of the one before, which counts the near pixels repeatedly; boxes rather than +discs let a summed-area table answer any radius in constant time, so the pass +stays linear in the canvas. + +`RgbaImage.rgba` is row-major, unpremultiplied RGBA in a `Uint8Array` or +`Uint8ClampedArray`. Image decoding remains the host's responsibility: Canvas +in a browser, the application's native image library in React Native, or a Node +image library in a CLI. diff --git a/lib/pintr-core/checkpoint.ts b/lib/pintr-core/checkpoint.ts new file mode 100644 index 0000000..102fcf3 --- /dev/null +++ b/lib/pintr-core/checkpoint.ts @@ -0,0 +1,189 @@ +import type { PintrConfig, PintrLine, PintrPoint } from './types'; +import { PintrError } from './types'; + +const MAGIC = [80, 73, 78, 84, 82, 67, 80, 49] as const; // PINTRCP1 +const HEADER_SIZE = 20; +const PAYLOAD_HEADER_SIZE = 64; +const FORMAT_VERSION = 1; +const ALGORITHM_VERSION = 1; +const BYTES_PER_LINE = 16; +const MAX_PIXELS = 100_000_000; + +export type CheckpointState = { + width: number; + height: number; + config: PintrConfig; + seed: number; + randomState: number; + cursor: PintrPoint; + gray: Uint8Array; + lines: PintrLine[]; + pendingCount: number; +}; + +function checksum(bytes: Uint8Array, start: number) { + let hash = 0x811c9dc5; + for (let i = start; i < bytes.length; i++) { + hash ^= bytes[i]; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +function fail(message: string): never { + throw new PintrError(`invalid checkpoint: ${message}`); +} + +export function encodeCheckpoint(state: CheckpointState) { + const totalLength = + HEADER_SIZE + + PAYLOAD_HEADER_SIZE + + state.gray.length + + state.lines.length * BYTES_PER_LINE; + const bytes = new Uint8Array(totalLength); + const view = new DataView(bytes.buffer); + + for (let i = 0; i < MAGIC.length; i++) bytes[i] = MAGIC[i]; + view.setUint16(8, FORMAT_VERSION, true); + view.setUint16(10, ALGORITHM_VERSION, true); + view.setUint32(12, totalLength, true); + + let offset = HEADER_SIZE; + view.setUint32(offset, state.width, true); + offset += 4; + view.setUint32(offset, state.height, true); + offset += 4; + view.setFloat64(offset, state.config.contrast, true); + offset += 8; + view.setFloat64(offset, state.config.definition, true); + offset += 8; + view.setFloat64(offset, state.config.strokeWidth, true); + offset += 8; + view.setUint8(offset, state.config.singleLine ? 1 : 0); + offset += 4; // one boolean plus three reserved bytes + view.setUint32(offset, state.seed, true); + offset += 4; + view.setUint32(offset, state.randomState, true); + offset += 4; + view.setUint32(offset, state.cursor[0], true); + offset += 4; + view.setUint32(offset, state.cursor[1], true); + offset += 4; + view.setUint32(offset, state.lines.length, true); + offset += 4; + view.setUint32(offset, state.pendingCount, true); + offset += 4; + view.setUint32(offset, state.gray.length, true); + offset += 4; + + bytes.set(state.gray, offset); + offset += state.gray.length; + + for (const line of state.lines) { + view.setUint32(offset, line[0][0], true); + view.setUint32(offset + 4, line[0][1], true); + view.setUint32(offset + 8, line[1][0], true); + view.setUint32(offset + 12, line[1][1], true); + offset += BYTES_PER_LINE; + } + + view.setUint32(16, checksum(bytes, HEADER_SIZE), true); + return bytes; +} + +export function decodeCheckpoint(input: Uint8Array): CheckpointState { + const bytes = new Uint8Array(input); + if (bytes.length < HEADER_SIZE + PAYLOAD_HEADER_SIZE) fail('truncated header'); + + for (let i = 0; i < MAGIC.length; i++) { + if (bytes[i] !== MAGIC[i]) fail('wrong magic header'); + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint16(8, true) !== FORMAT_VERSION) { + fail('unsupported format version'); + } + if (view.getUint16(10, true) !== ALGORITHM_VERSION) { + fail('unsupported algorithm version'); + } + if (view.getUint32(12, true) !== bytes.length) fail('wrong byte length'); + if (view.getUint32(16, true) !== checksum(bytes, HEADER_SIZE)) { + fail('checksum mismatch'); + } + + let offset = HEADER_SIZE; + const width = view.getUint32(offset, true); + offset += 4; + const height = view.getUint32(offset, true); + offset += 4; + const contrast = view.getFloat64(offset, true); + offset += 8; + const definition = view.getFloat64(offset, true); + offset += 8; + const strokeWidth = view.getFloat64(offset, true); + offset += 8; + const singleLineByte = view.getUint8(offset); + offset += 4; + const seed = view.getUint32(offset, true); + offset += 4; + const randomState = view.getUint32(offset, true); + offset += 4; + const cursorX = view.getUint32(offset, true); + offset += 4; + const cursorY = view.getUint32(offset, true); + offset += 4; + const lineCount = view.getUint32(offset, true); + offset += 4; + const pendingCount = view.getUint32(offset, true); + offset += 4; + const grayLength = view.getUint32(offset, true); + offset += 4; + + if (!width || !height || width * height > MAX_PIXELS) { + fail('invalid image dimensions'); + } + if (grayLength !== width * height) fail('wrong grayscale length'); + if (singleLineByte > 1) fail('invalid singleLine value'); + if (pendingCount > lineCount) fail('pending lines exceed line count'); + if (cursorX >= width || cursorY >= height) fail('cursor is outside the image'); + + const expectedLength = + HEADER_SIZE + + PAYLOAD_HEADER_SIZE + + grayLength + + lineCount * BYTES_PER_LINE; + if (expectedLength !== bytes.length) fail('inconsistent payload lengths'); + + const gray = bytes.slice(offset, offset + grayLength); + offset += grayLength; + const lines: PintrLine[] = []; + + for (let i = 0; i < lineCount; i++) { + const fromX = view.getUint32(offset, true); + const fromY = view.getUint32(offset + 4, true); + const toX = view.getUint32(offset + 8, true); + const toY = view.getUint32(offset + 12, true); + offset += BYTES_PER_LINE; + + if (fromX >= width || toX >= width || fromY >= height || toY >= height) { + fail('line coordinate is outside the image'); + } + + lines.push([ + [fromX, fromY], + [toX, toY], + ]); + } + + return { + width, + height, + config: { contrast, definition, singleLine: Boolean(singleLineByte), strokeWidth }, + seed, + randomState, + cursor: [cursorX, cursorY], + gray, + lines, + pendingCount, + }; +} diff --git a/lib/pintr-core/createPintr.ts b/lib/pintr-core/createPintr.ts new file mode 100644 index 0000000..31f39fd --- /dev/null +++ b/lib/pintr-core/createPintr.ts @@ -0,0 +1,251 @@ +import { decodeCheckpoint, encodeCheckpoint } from './checkpoint'; +import { tweenValue } from './math'; +import { createRandom } from './random'; +import { scanLine } from './scan'; +import { createSource } from './source'; +import type { + PintrBatch, + PintrCheckpoint, + PintrConfig, + PintrImage, + PintrLine, + PintrPoint, + PintrSession, +} from './types'; +import { PintrError } from './types'; + +const MAX_PIXELS = 100_000_000; + +type RestoredState = { + randomState: number; + cursor: PintrPoint; + gray: Uint8Array; + lines: PintrLine[]; + pendingCount: number; +}; + +function validateImage(image: PintrImage) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height)) { + throw new PintrError('image dimensions must be integers'); + } + if (image.width <= 0 || image.height <= 0) { + throw new PintrError('image dimensions must be positive'); + } + if (image.width * image.height > MAX_PIXELS) { + throw new PintrError('image is too large'); + } + if (!(image.gray instanceof Uint8Array)) { + throw new PintrError('image.gray must be a Uint8Array'); + } + if (image.gray.length !== image.width * image.height) { + throw new PintrError('image.gray has the wrong length'); + } +} + +function validateConfig(config: PintrConfig) { + if (!Number.isFinite(config.contrast) || config.contrast < 0 || config.contrast > 100) { + throw new PintrError('contrast must be between 0 and 100'); + } + if ( + !Number.isFinite(config.definition) || + config.definition < 0 || + config.definition > 100 + ) { + throw new PintrError('definition must be between 0 and 100'); + } + if (typeof config.singleLine !== 'boolean') { + throw new PintrError('singleLine must be a boolean'); + } + if (!Number.isFinite(config.strokeWidth) || config.strokeWidth <= 0) { + throw new PintrError('strokeWidth must be greater than zero'); + } +} + +function createSession( + image: PintrImage, + inputConfig: PintrConfig, + seed: number, + restored?: RestoredState +): PintrSession { + validateImage(image); + validateConfig(inputConfig); + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) { + throw new PintrError('seed must be a uint32'); + } + + const config = { ...inputConfig }; + const tweenDefinition = Math.round( + tweenValue(config.definition, [ + [0, 3], + [50, 15], + [100, 75], + ]) + ); + const feedbackAlpha = + (100 - + Math.round( + tweenValue(config.contrast, [ + [0, 20], + [50, 67], + [100, 90], + ]) + )) / + 100; + const updateSampleRate = 100 - Math.floor(tweenDefinition / 2); + const source = createSource( + image.width, + image.height, + restored ? restored.gray : image.gray + ); + const random = createRandom(seed, restored?.randomState); + const lines: PintrLine[] = restored ? restored.lines : []; + let pendingLines: PintrLine[] = restored + ? lines.slice(lines.length - restored.pendingCount) + : []; + let cursor: PintrPoint = restored + ? restored.cursor + : [Math.floor(image.width / 2), Math.floor(image.height / 2)]; + + function drawSequenceLine() { + let from = cursor; + + // if no `singleLine` hop to different points to find a new cursor + let toFrom = config.singleLine ? 0 : tweenDefinition; + while (toFrom--) { + const tmpFrom: PintrPoint = [ + random.int(image.width), + random.int(image.height), + ]; + if ( + source.gray[from[0] * image.height + from[1]] > + source.gray[tmpFrom[0] * image.height + tmpFrom[1]] + ) { + from = tmpFrom; + } + } + + // now we look at different places to expand + let remainingCursorsToExplore = random.int( + tweenDefinition, + tweenDefinition * 2 + ); + let to: PintrPoint = [random.int(image.width), random.int(image.height)]; + let light = 255; + while (remainingCursorsToExplore--) { + const tmpTo: PintrPoint = [ + random.int(image.width), + random.int(image.height), + ]; + const tmpLight = scanLine(from, tmpTo, source.gray, image.height); + if (tmpLight <= light) { + light = tmpLight; + to = tmpTo; + } + } + light = scanLine(from, to, source.gray, image.height); + + const line: PintrLine = [from, to]; + lines.push(line); + pendingLines.push(line); + cursor = line[1]; + + return line; + } + + function next(lineCount: number): PintrBatch { + if (!Number.isInteger(lineCount) || lineCount <= 0) { + throw new PintrError('next() lineCount must be a positive integer'); + } + + const startLine = lines.length; + const batch: PintrLine[] = []; + + for (let i = 0; i < lineCount; i++) { + // Keep this before the next line: exactly R generated lines remain pending + // until line R asks the source to refresh. + if (lines.length % updateSampleRate === 0) { + source.erase(pendingLines, config.strokeWidth * 1.5, feedbackAlpha); + pendingLines = []; + } + batch.push(drawSequenceLine()); + } + + return { + startLine, + endLine: lines.length, + lines: batch, + }; + } + + function getLines(from = 0, to = lines.length) { + if ( + !Number.isInteger(from) || + !Number.isInteger(to) || + from < 0 || + to < from || + to > lines.length + ) { + throw new PintrError('getLines() range is outside generated lines'); + } + + return lines.slice(from, to); + } + + function save(): PintrCheckpoint { + return encodeCheckpoint({ + width: image.width, + height: image.height, + config, + seed, + randomState: random.state, + cursor, + gray: source.snapshot(), + lines, + pendingCount: pendingLines.length, + }); + } + + return { + width: image.width, + height: image.height, + seed, + get lineCount() { + return lines.length; + }, + next, + getLines, + save, + }; +} + +export function createPintr(input: { + image: PintrImage; + config: PintrConfig; + seed?: number; +}) { + const seed = + input.seed === undefined + ? Math.floor(Math.random() * 0x100000000) + : input.seed; + return createSession(input.image, input.config, seed); +} + +export function restorePintr(checkpoint: PintrCheckpoint) { + if (!(checkpoint instanceof Uint8Array)) { + throw new PintrError('checkpoint must be a Uint8Array'); + } + + const state = decodeCheckpoint(checkpoint); + return createSession( + { width: state.width, height: state.height, gray: state.gray }, + state.config, + state.seed, + { + randomState: state.randomState, + cursor: state.cursor, + gray: state.gray, + lines: state.lines, + pendingCount: state.pendingCount, + } + ); +} diff --git a/lib/pintr-core/index.ts b/lib/pintr-core/index.ts new file mode 100644 index 0000000..6c07359 --- /dev/null +++ b/lib/pintr-core/index.ts @@ -0,0 +1,11 @@ +export { createPintr, restorePintr } from './createPintr'; +export { PintrError } from './types'; +export type { + PintrBatch, + PintrCheckpoint, + PintrConfig, + PintrImage, + PintrLine, + PintrPoint, + PintrSession, +} from './types'; diff --git a/lib/pintr-core/math.ts b/lib/pintr-core/math.ts new file mode 100644 index 0000000..48582b5 --- /dev/null +++ b/lib/pintr-core/math.ts @@ -0,0 +1,29 @@ +export function tweenValue( + value: number, + tweens: readonly (readonly [number, number])[] +): number { + const sortedTweens = [...tweens].sort((a, b) => a[0] - b[0]); + + if ( + value < sortedTweens[0][0] || + value > sortedTweens[sortedTweens.length - 1][0] + ) { + return value; + } + + for (let i = 0; i < sortedTweens.length; i++) { + const tween = sortedTweens[i]; + + if (tween[0] === value) return tween[1]; + + if (tween[0] > value) { + const previousTween = sortedTweens[i - 1]; + const range = tween[0] - previousTween[0]; + const progress = (value - previousTween[0]) / range; + + return progress * tween[1] + (1 - progress) * previousTween[1]; + } + } + + return value; +} diff --git a/lib/pintr-core/random.ts b/lib/pintr-core/random.ts new file mode 100644 index 0000000..e764ddc --- /dev/null +++ b/lib/pintr-core/random.ts @@ -0,0 +1,34 @@ +const STEP = 0x6d2b79f5; + +export type PintrRandom = ReturnType; + +// PINTR only needs one uint32 of random state. The call sites stay in the same +// order as Math.random did, while checkpoints can now continue the sequence. +export function createRandom(seed: number, restoredState?: number) { + let state = (restoredState === undefined ? seed : restoredState) >>> 0; + + function next() { + state = (state + STEP) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + } + + function int(start: number, end?: number) { + if (end === undefined) return Math.floor(next() * start); + + return ( + Math.min(start, end) + Math.floor(next() * Math.abs(start - end)) + ); + } + + return { + next, + int, + get state() { + return state; + }, + }; +} diff --git a/lib/scan.ts b/lib/pintr-core/scan.ts similarity index 70% rename from lib/scan.ts rename to lib/pintr-core/scan.ts index 36cbc56..c06eccf 100644 --- a/lib/scan.ts +++ b/lib/pintr-core/scan.ts @@ -1,6 +1,11 @@ -import type { Coord } from './PINTR'; +import type { PintrPoint } from './types'; -export function scanLine(from: Coord, to: Coord, matrix: Uint8Array[]) { +export function scanLine( + from: PintrPoint, + to: PintrPoint, + gray: Uint8Array, + height: number +) { let total = 0; const x = from[0]; @@ -16,7 +21,7 @@ export function scanLine(from: Coord, to: Coord, matrix: Uint8Array[]) { for (let i = 0; i < precision; i++) { const scanX = Math.round(x + stepX * i); const scanY = Math.round(y + stepY * i); - total += matrix[scanX][scanY]; + total += gray[scanX * height + scanY]; } return Math.round(total / precision); diff --git a/lib/pintr-core/source.ts b/lib/pintr-core/source.ts new file mode 100644 index 0000000..281b652 --- /dev/null +++ b/lib/pintr-core/source.ts @@ -0,0 +1,120 @@ +import type { PintrLine } from './types'; + +const SAMPLES = 4; +const SAMPLE_COUNT = SAMPLES * SAMPLES; +const EPSILON = 1e-9; + +function countBits16(value: number) { + value -= (value >>> 1) & 0x5555; + value = (value & 0x3333) + ((value >>> 2) & 0x3333); + value = (value + (value >>> 4)) & 0x0f0f; + value += value >>> 8; + return value & 0x1f; +} + +// The browser strokes every buffered segment as one path, so overlaps are a +// union. A bit per sub-pixel keeps that union instead of blending twice. +export function createSource( + width: number, + height: number, + initialGray: Uint8Array +) { + const gray = new Uint8Array(initialGray); + const coverage = new Uint16Array(width * height); + const touched: number[] = []; + + function coverLine(line: PintrLine, strokeWidth: number) { + const x1 = line[0][0]; + const y1 = line[0][1]; + const x2 = line[1][0]; + const y2 = line[1][1]; + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.hypot(dx, dy); + + // A zero-length subpath with a butt cap paints nothing. + if (!length) return; + + const half = strokeWidth / 2; + const nx = (-dy / length) * half; + const ny = (dx / length) * half; + const xs = [x1 + nx, x2 + nx, x2 - nx, x1 - nx]; + const ys = [y1 + ny, y2 + ny, y2 - ny, y1 - ny]; + + const minY = Math.max(0, Math.floor(Math.min(...ys) - 1)); + const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys) + 1)); + + for (let y = minY; y <= maxY; y++) { + for (let sampleY = 0; sampleY < SAMPLES; sampleY++) { + const py = y + (sampleY + 0.5) / SAMPLES; + let left = Infinity; + let right = -Infinity; + + for (let edge = 0; edge < 4; edge++) { + const nextEdge = (edge + 1) % 4; + const ay = ys[edge]; + const by = ys[nextEdge]; + + if (!((ay <= py && by > py) || (by <= py && ay > py))) continue; + + const progress = (py - ay) / (by - ay); + const px = xs[edge] + progress * (xs[nextEdge] - xs[edge]); + left = Math.min(left, px); + right = Math.max(right, px); + } + + if (left === Infinity) continue; + + const minX = Math.max(0, Math.floor(left) - 1); + const maxX = Math.min(width - 1, Math.ceil(right) + 1); + + for (let x = minX; x <= maxX; x++) { + let bits = 0; + + for (let sampleX = 0; sampleX < SAMPLES; sampleX++) { + const px = x + (sampleX + 0.5) / SAMPLES; + if (px >= left - EPSILON && px <= right + EPSILON) { + bits |= 1 << (sampleY * SAMPLES + sampleX); + } + } + + if (!bits) continue; + + const pixel = y * width + x; + if (!coverage[pixel]) touched.push(pixel); + coverage[pixel] |= bits; + } + } + } + } + + function erase( + lines: readonly PintrLine[], + strokeWidth: number, + alpha: number + ) { + if (!lines.length || alpha <= 0 || strokeWidth <= 0) return; + + for (const line of lines) coverLine(line, strokeWidth); + + for (const pixel of touched) { + const x = pixel % width; + const y = Math.floor(pixel / width); + const grayIndex = x * height + y; + const amount = (countBits16(coverage[pixel]) / SAMPLE_COUNT) * alpha; + const current = gray[grayIndex]; + gray[grayIndex] = Math.round(current + (255 - current) * amount); + coverage[pixel] = 0; + } + + touched.length = 0; + } + + return { + gray, + erase, + snapshot() { + return new Uint8Array(gray); + }, + }; +} diff --git a/lib/pintr-core/types.ts b/lib/pintr-core/types.ts new file mode 100644 index 0000000..79c8781 --- /dev/null +++ b/lib/pintr-core/types.ts @@ -0,0 +1,42 @@ +export type PintrPoint = [x: number, y: number]; +export type PintrLine = [from: PintrPoint, to: PintrPoint]; + +// The core starts after image preparation. Keeping this shape small means a +// browser, native app, or CLI only has to agree on pixels — never on decoders. +export type PintrImage = { + width: number; + height: number; + gray: Uint8Array; +}; + +export type PintrConfig = { + contrast: number; + definition: number; + singleLine: boolean; + strokeWidth: number; +}; + +export type PintrBatch = { + startLine: number; + endLine: number; + lines: PintrLine[]; +}; + +export type PintrCheckpoint = Uint8Array; + +export type PintrSession = { + width: number; + height: number; + seed: number; + lineCount: number; + next(lineCount: number): PintrBatch; + getLines(from?: number, to?: number): PintrLine[]; + save(): PintrCheckpoint; +}; + +export class PintrError extends Error { + constructor(message: string) { + super(`PINTR: ${message}`); + this.name = 'PintrError'; + } +} diff --git a/lib/pintr-core/utils/applyVignette.ts b/lib/pintr-core/utils/applyVignette.ts new file mode 100644 index 0000000..e063aa8 --- /dev/null +++ b/lib/pintr-core/utils/applyVignette.ts @@ -0,0 +1,52 @@ +import { clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type VignetteOptions = { + vignetteDistance: number; + vignetteHardness: number; + vignetteSquare: boolean; +}; + +export function applyVignette( + image: RgbaImage, + { vignetteDistance, vignetteHardness, vignetteSquare }: VignetteOptions +): RgbaImage { + validateRgbaImage(image); + const { width: W, height: H } = image; + const rgba = new Uint8ClampedArray(image.rgba); + const SHORT_SIDE = Math.min(W, H); + const cx = W / 2; + const cy = H / 2; + + // distance in px; 0 = strongest (fade at the inscribed edge), raising it pushes + // the fade outward toward the corners until it disappears. + const VD = (clamp(vignetteDistance, 0, 100) / 100) * (SHORT_SIDE / 2); + const hardness = clamp(vignetteHardness, 0, 100) / 100; + const outer = VD + SHORT_SIDE / 2; + const inner = outer - (1 - hardness) * SHORT_SIDE; + const denom = Math.max(1e-6, outer - inner); + const hx = vignetteSquare ? SHORT_SIDE / 2 : W / 2; + const hy = vignetteSquare ? SHORT_SIDE / 2 : H / 2; + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const d = + (SHORT_SIDE / 2) * Math.hypot((x - cx) / hx, (y - cy) / hy); + + let alpha: number; + if (d <= inner) alpha = 1; + else if (d >= outer) alpha = 0; + else alpha = (outer - d) / denom; + + if (alpha >= 1) continue; + + const i = (y * W + x) * 4; + const white = (1 - alpha) * 255; + rgba[i] = rgba[i] * alpha + white; + rgba[i + 1] = rgba[i + 1] * alpha + white; + rgba[i + 2] = rgba[i + 2] * alpha + white; + } + } + + return { width: W, height: H, rgba }; +} diff --git a/lib/pintr-core/utils/extendRgbaImage.ts b/lib/pintr-core/utils/extendRgbaImage.ts new file mode 100644 index 0000000..c3ef7b4 --- /dev/null +++ b/lib/pintr-core/utils/extendRgbaImage.ts @@ -0,0 +1,182 @@ +import { PintrError } from '../types'; +import { clamp, toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type ExtendOptions = { + // The canvas the picture is being placed on, and where it sits inside it. + canvasWidth: number; + canvasHeight: number; + offsetX: number; + offsetY: number; +}; + +// How many nested boxes the weighted average blends, each half the radius of the +// one before. Four octaves put roughly eighty times more weight on a pixel +// against the picture than on one at the far edge of the window, while still +// giving every scale a comparable share of the total — so the fill keeps the +// structure it is nearest to and stays self-similar as the radius grows. +const LEVELS = 4; + +// Fill everything around a picture with a plausible continuation of it. +// +// For a pixel outside the picture, the nearest picture pixel is just a clamp of +// its coordinates — the picture is always an axis-aligned rectangle, so no +// distance transform is needed. The colour written is then an average of the +// picture pixels within that same distance of the nearest pixel, weighted so the +// ones closest to it count for most: touching the edge it is the edge pixel +// itself, and the further out it travels the wider it averages, until the far +// corners settle on the picture's overall tone. It reads as the picture +// diffusing outward rather than the edge pixel smearing. +// +// The weighting is what nested boxes buy. A single box would let a pixel at the +// rim of the window pull as hard as one against the picture, which flattens the +// structure the fill is supposed to continue; averaging boxes of shrinking +// radius counts the near ones repeatedly, so weight falls off with distance. +// Boxes rather than discs are what make it affordable: a summed-area table +// answers any box in four lookups, so the pass stays linear in the canvas +// however large the radii grow. +export function extendRgbaImage( + picture: RgbaImage, + { canvasWidth, canvasHeight, offsetX, offsetY }: ExtendOptions +): RgbaImage { + validateRgbaImage(picture); + if (!Number.isInteger(canvasWidth) || !Number.isInteger(canvasHeight)) { + throw new PintrError('extend canvas dimensions must be integers'); + } + if (canvasWidth <= 0 || canvasHeight <= 0) { + throw new PintrError('extend canvas dimensions must be positive'); + } + if (!Number.isInteger(offsetX) || !Number.isInteger(offsetY)) { + throw new PintrError('extend offsets must be integers'); + } + + const { width, height } = picture; + const source = picture.rgba; + + // A row and a column of zeros ahead of the picture so the four-corner lookup + // needs no bounds tests. Every entry sums at most 100M bytes, well inside + // Uint32, so the sums stay exact and the fill stays deterministic. + const satWidth = width + 1; + const sat = new Uint32Array(satWidth * (height + 1) * 4); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixel = (y * width + x) * 4; + const current = ((y + 1) * satWidth + x + 1) * 4; + const above = (y * satWidth + x + 1) * 4; + const before = ((y + 1) * satWidth + x) * 4; + const diagonal = (y * satWidth + x) * 4; + + for (let channel = 0; channel < 4; channel++) { + sat[current + channel] = + source[pixel + channel] + + sat[above + channel] + + sat[before + channel] - + sat[diagonal + channel]; + } + } + } + + const spare = new Float64Array(4); + + // Mean of the box of the given whole radius around a picture pixel, clipped to + // the picture — outside it there is nothing to average. + function boxMean( + centreX: number, + centreY: number, + radius: number, + target: Float64Array + ) { + const x0 = Math.max(0, centreX - radius); + const y0 = Math.max(0, centreY - radius); + const x1 = Math.min(width - 1, centreX + radius); + const y1 = Math.min(height - 1, centreY + radius); + const count = (x1 - x0 + 1) * (y1 - y0 + 1); + + const topLeft = (y0 * satWidth + x0) * 4; + const topRight = (y0 * satWidth + x1 + 1) * 4; + const bottomLeft = ((y1 + 1) * satWidth + x0) * 4; + const bottomRight = ((y1 + 1) * satWidth + x1 + 1) * 4; + + for (let channel = 0; channel < 4; channel++) { + target[channel] = + (sat[bottomRight + channel] - + sat[topRight + channel] - + sat[bottomLeft + channel] + + sat[topLeft + channel]) / + count; + } + } + + // The same for a fractional radius. Blending the two whole radii keeps every + // box continuous with the distance that chose it; rounding instead makes the + // contours of that rounding — rounded rectangles around the picture — visible + // as bands wherever the picture is smooth. + function boxMeanAt( + centreX: number, + centreY: number, + radius: number, + target: Float64Array + ) { + const whole = Math.floor(radius); + const fraction = radius - whole; + + boxMean(centreX, centreY, whole, target); + if (fraction > 0) { + boxMean(centreX, centreY, whole + 1, spare); + for (let channel = 0; channel < 4; channel++) { + target[channel] += (spare[channel] - target[channel]) * fraction; + } + } + } + + const rgba = new Uint8ClampedArray(canvasWidth * canvasHeight * 4); + const level = new Float64Array(4); + const weighted = new Float64Array(4); + + for (let y = 0; y < canvasHeight; y++) { + const sourceY = y - offsetY; + const sampleY = clamp(sourceY, 0, height - 1); + const dy = sourceY - sampleY; + + for (let x = 0; x < canvasWidth; x++) { + const sourceX = x - offsetX; + const sampleX = clamp(sourceX, 0, width - 1); + const dx = sourceX - sampleX; + const target = (y * canvasWidth + x) * 4; + + // The clamp moved nothing, so this pixel is the picture: copy it verbatim + // rather than averaging over it. + if (dx === 0 && dy === 0) { + const pixel = (sampleY * width + sampleX) * 4; + rgba[target] = source[pixel]; + rgba[target + 1] = source[pixel + 1]; + rgba[target + 2] = source[pixel + 2]; + rgba[target + 3] = source[pixel + 3]; + continue; + } + + weighted[0] = 0; + weighted[1] = 0; + weighted[2] = 0; + weighted[3] = 0; + + let radius = Math.sqrt(dx * dx + dy * dy); + for (let step = 0; step < LEVELS; step++) { + boxMeanAt(sampleX, sampleY, radius, level); + weighted[0] += level[0]; + weighted[1] += level[1]; + weighted[2] += level[2]; + weighted[3] += level[3]; + radius /= 2; + } + + rgba[target] = toUint8Clamp(weighted[0] / LEVELS); + rgba[target + 1] = toUint8Clamp(weighted[1] / LEVELS); + rgba[target + 2] = toUint8Clamp(weighted[2] / LEVELS); + rgba[target + 3] = toUint8Clamp(weighted[3] / LEVELS); + } + } + + return { width: canvasWidth, height: canvasHeight, rgba }; +} diff --git a/lib/pintr-core/utils/frameRgbaImage.ts b/lib/pintr-core/utils/frameRgbaImage.ts new file mode 100644 index 0000000..53f067e --- /dev/null +++ b/lib/pintr-core/utils/frameRgbaImage.ts @@ -0,0 +1,90 @@ +import { PintrError } from '../types'; +import { extendRgbaImage } from './extendRgbaImage'; +import { resizeRgbaImage } from './resizeRgbaImage'; +import { clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type FrameOptions = { + // width / height of the output canvas; null keeps the source ratio. + aspectRatio: number | null; + // 0-100, percent of the canvas short side, inset on each of the four sides. + padding: number; + // Fill the space around the picture with a blurred continuation of it + // instead of leaving it white. + extend?: boolean; +}; + +// Place the image, contained (never cropped, never stretched), on an opaque +// white canvas of the requested aspect ratio. The ratio alone decides the output +// dimensions: padding insets the picture inside that canvas instead of growing +// it, so the ratio stays exact. White is what PINTR reads as empty, so the +// letterbox and margin simply produce no lines — unless `extend` diffuses the +// picture into them, which makes the drawing reach the frame. +export function frameRgbaImage( + image: RgbaImage, + { aspectRatio, padding, extend = false }: FrameOptions +): RgbaImage { + validateRgbaImage(image); + if (aspectRatio !== null && (!Number.isFinite(aspectRatio) || aspectRatio <= 0)) { + throw new PintrError('frame aspectRatio must be a positive number or null'); + } + if (!Number.isFinite(padding) || padding < 0 || padding >= 50) { + throw new PintrError('frame padding must be between 0 and 50'); + } + + const longest = Math.max(image.width, image.height); + const ratio = aspectRatio ?? image.width / image.height; + const canvasWidth = ratio >= 1 ? longest : Math.max(1, Math.round(longest * ratio)); + const canvasHeight = ratio >= 1 ? Math.max(1, Math.round(longest / ratio)) : longest; + + const pad = Math.round( + (clamp(padding, 0, 100) / 100) * Math.min(canvasWidth, canvasHeight) + ); + const innerWidth = Math.max(1, canvasWidth - pad * 2); + const innerHeight = Math.max(1, canvasHeight - pad * 2); + + // Contain preserves the source ratio, so the scaled longest side is exactly + // what resizeRgbaImage takes — no second resampler needed here. + const scale = Math.min(innerWidth / image.width, innerHeight / image.height); + const scaled = + scale >= 1 + ? image + : resizeRgbaImage(image, { + longestSide: Math.max(1, Math.round(longest * scale)), + }); + + const offsetX = Math.round((canvasWidth - scaled.width) / 2); + const offsetY = Math.round((canvasHeight - scaled.height) / 2); + + if (extend) { + return extendRgbaImage(scaled, { + canvasWidth, + canvasHeight, + offsetX, + offsetY, + }); + } + + // Walk the canvas rather than the picture: everything the picture does not + // cover keeps the white the buffer was filled with. + const rgba = new Uint8ClampedArray(canvasWidth * canvasHeight * 4).fill(255); + + for (let y = 0; y < canvasHeight; y++) { + const sourceY = y - offsetY; + if (sourceY < 0 || sourceY >= scaled.height) continue; + + for (let x = 0; x < canvasWidth; x++) { + const sourceX = x - offsetX; + if (sourceX < 0 || sourceX >= scaled.width) continue; + + const source = (sourceY * scaled.width + sourceX) * 4; + const target = (y * canvasWidth + x) * 4; + rgba[target] = scaled.rgba[source]; + rgba[target + 1] = scaled.rgba[source + 1]; + rgba[target + 2] = scaled.rgba[source + 2]; + rgba[target + 3] = scaled.rgba[source + 3]; + } + } + + return { width: canvasWidth, height: canvasHeight, rgba }; +} diff --git a/lib/pintr-core/utils/index.ts b/lib/pintr-core/utils/index.ts new file mode 100644 index 0000000..509f259 --- /dev/null +++ b/lib/pintr-core/utils/index.ts @@ -0,0 +1,9 @@ +export { applyVignette } from './applyVignette'; +export type { VignetteOptions } from './applyVignette'; +export { extendRgbaImage } from './extendRgbaImage'; +export type { ExtendOptions } from './extendRgbaImage'; +export { frameRgbaImage } from './frameRgbaImage'; +export type { FrameOptions } from './frameRgbaImage'; +export { preparePintrImage } from './preparePintrImage'; +export { resizeRgbaImage } from './resizeRgbaImage'; +export type { RgbaImage } from './types'; diff --git a/lib/pintr-core/utils/preparePintrImage.ts b/lib/pintr-core/utils/preparePintrImage.ts new file mode 100644 index 0000000..51d3234 --- /dev/null +++ b/lib/pintr-core/utils/preparePintrImage.ts @@ -0,0 +1,60 @@ +import type { PintrImage } from '../types'; +import { toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +const RED_PREFERENCE = 0.1; +const WEIGHTS = { + r: 0.299 + RED_PREFERENCE, + g: 0.587 + RED_PREFERENCE * -0.5, + b: 0.114 + RED_PREFERENCE * -0.5, +}; + +export function preparePintrImage(image: RgbaImage): PintrImage { + validateRgbaImage(image); + const pixels = image.rgba; + let minLightness = Infinity; + let maxLightness = 0; + + for (let i = 0; i < pixels.length; i += 4) { + let lightness = + pixels[i] * WEIGHTS.r + + pixels[i + 1] * WEIGHTS.g + + pixels[i + 2] * WEIGHTS.b; + + // Keep the current split behavior: transparent pixels affect the min/max as + // white here, while the RGB normalization below still uses their RGB bytes. + if (pixels[i + 3] < 128) lightness = 255; + + minLightness = Math.min(minLightness, lightness); + maxLightness = Math.max(maxLightness, lightness); + } + + minLightness += 32; + maxLightness -= 32; + const contrast = 255 / (maxLightness - minLightness); + const gray = new Uint8Array(image.width * image.height); + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const rgbaIndex = (y * image.width + x) * 4; + const lightness = + pixels[rgbaIndex] * WEIGHTS.r + + pixels[rgbaIndex + 1] * WEIGHTS.g + + pixels[rgbaIndex + 2] * WEIGHTS.b; + const normalized = toUint8Clamp( + Math.round(lightness * contrast) - minLightness + ); + const alpha = pixels[rgbaIndex + 3]; + const composited = + alpha === 255 + ? normalized + : Math.round( + normalized * (alpha / 255) + 255 * (1 - alpha / 255) + ); + + gray[x * image.height + y] = composited; + } + } + + return { width: image.width, height: image.height, gray }; +} diff --git a/lib/pintr-core/utils/resizeRgbaImage.ts b/lib/pintr-core/utils/resizeRgbaImage.ts new file mode 100644 index 0000000..b497f18 --- /dev/null +++ b/lib/pintr-core/utils/resizeRgbaImage.ts @@ -0,0 +1,60 @@ +import { PintrError } from '../types'; +import { toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export function resizeRgbaImage( + image: RgbaImage, + { + longestSide, + allowUpscale = false, + }: { longestSide: number; allowUpscale?: boolean } +): RgbaImage { + validateRgbaImage(image); + if (!Number.isInteger(longestSide) || longestSide <= 0) { + throw new PintrError('resize longestSide must be a positive integer'); + } + + const scale = longestSide / Math.max(image.width, image.height); + if (!allowUpscale && scale >= 1) { + return { + width: image.width, + height: image.height, + rgba: new Uint8ClampedArray(image.rgba), + }; + } + + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const rgba = new Uint8ClampedArray(width * height * 4); + + // Pixel-center bilinear sampling is deterministic and deliberately lives in + // utils; the browser may keep drawImage when matching its current pixels. + for (let y = 0; y < height; y++) { + const sourceY = (y + 0.5) / scale - 0.5; + const y0 = Math.max(0, Math.floor(sourceY)); + const y1 = Math.min(image.height - 1, y0 + 1); + const fy = Math.max(0, sourceY - y0); + + for (let x = 0; x < width; x++) { + const sourceX = (x + 0.5) / scale - 0.5; + const x0 = Math.max(0, Math.floor(sourceX)); + const x1 = Math.min(image.width - 1, x0 + 1); + const fx = Math.max(0, sourceX - x0); + const target = (y * width + x) * 4; + + for (let channel = 0; channel < 4; channel++) { + const topLeft = image.rgba[(y0 * image.width + x0) * 4 + channel]; + const topRight = image.rgba[(y0 * image.width + x1) * 4 + channel]; + const bottomLeft = image.rgba[(y1 * image.width + x0) * 4 + channel]; + const bottomRight = image.rgba[(y1 * image.width + x1) * 4 + channel]; + const top = topLeft * (1 - fx) + topRight * fx; + const bottom = bottomLeft * (1 - fx) + bottomRight * fx; + rgba[target + channel] = toUint8Clamp( + top * (1 - fy) + bottom * fy + ); + } + } + } + + return { width, height, rgba }; +} diff --git a/lib/pintr-core/utils/shared.ts b/lib/pintr-core/utils/shared.ts new file mode 100644 index 0000000..a7d749a --- /dev/null +++ b/lib/pintr-core/utils/shared.ts @@ -0,0 +1,44 @@ +import type { RgbaImage } from './types'; +import { PintrError } from '../types'; + +const MAX_PIXELS = 100_000_000; + +export function validateRgbaImage(image: RgbaImage) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height)) { + throw new PintrError('RGBA image dimensions must be integers'); + } + if (image.width <= 0 || image.height <= 0) { + throw new PintrError('RGBA image dimensions must be positive'); + } + if (image.width * image.height > MAX_PIXELS) { + throw new PintrError('RGBA image is too large'); + } + if ( + !(image.rgba instanceof Uint8Array) && + !(image.rgba instanceof Uint8ClampedArray) + ) { + throw new PintrError('RGBA pixels must be a Uint8Array'); + } + if (image.rgba.length !== image.width * image.height * 4) { + throw new PintrError('RGBA image has the wrong byte length'); + } +} + +export function clamp(value: number, start: number, end: number) { + const min = Math.min(start, end); + const max = Math.max(start, end); + return Math.min(Math.max(value, min), max); +} + +// ImageData writes use ToUint8Clamp rather than Uint8Array's modulo behavior. +// Keeping it explicit makes browser and CLI byte buffers prepare identically. +export function toUint8Clamp(value: number) { + if (Number.isNaN(value) || value <= 0) return 0; + if (value >= 255) return 255; + + const floor = Math.floor(value); + const fraction = value - floor; + if (fraction > 0.5) return floor + 1; + if (fraction < 0.5) return floor; + return floor % 2 ? floor + 1 : floor; +} diff --git a/lib/pintr-core/utils/types.ts b/lib/pintr-core/utils/types.ts new file mode 100644 index 0000000..845686d --- /dev/null +++ b/lib/pintr-core/utils/types.ts @@ -0,0 +1,5 @@ +export type RgbaImage = { + width: number; + height: number; + rgba: Uint8Array | Uint8ClampedArray; +}; diff --git a/lib/preprocess.ts b/lib/preprocess.ts new file mode 100644 index 0000000..9cb6a89 --- /dev/null +++ b/lib/preprocess.ts @@ -0,0 +1,133 @@ +import { applyVignette, frameRgbaImage } from './pintr-core/utils'; +import type { RgbaImage } from './pintr-core/utils'; + +// Cap the working canvas at the size PINTR itself draws at, so the per-pixel +// pass stays fast, the resulting data URL small, and padding percentages mean +// the same thing whatever the source resolution. +const MAX_SIDE = 1080; + +export type SourceConfig = { + // '' keeps the source ratio; otherwise 'W:H'. + aspectRatio: string; + // 0-100, percent of the canvas short side. + padding: number; + extend: boolean; + vignette: boolean; + vignetteDistance: number; + vignetteHardness: number; + vignetteSquare: boolean; +}; + +export type PreprocessedImage = { + src: string; + width: number; + height: number; +}; + +// Decoding an image is the slow part, so keep the last decoded source canvas +// around — dragging a pre-processing slider re-runs the pixel pass but not the decode. +let cache: { src: string; canvas: HTMLCanvasElement } | null = null; + +function loadImageEl(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +async function getSourceCanvas(imgSrc: string): Promise { + if (cache && cache.src === imgSrc) return cache.canvas; + + const img = await loadImageEl(imgSrc); + const ratio = Math.min(1, MAX_SIDE / Math.max(img.width, img.height)); + const w = Math.max(1, Math.round(img.width * ratio)); + const h = Math.max(1, Math.round(img.height * ratio)); + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Failed to 'getContext2d'"); + ctx.drawImage(img, 0, 0, w, h); + + cache = { src: imgSrc, canvas }; + return canvas; +} + +// 'W:H' -> W / H. Anything unparseable keeps the source ratio. +function parseAspectRatio(value: string): number | null { + const [w, h] = value.split(':').map(Number); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; + return w / h; +} + +function isIdentity(config: SourceConfig): boolean { + return ( + !config.vignette && + parseAspectRatio(config.aspectRatio) === null && + config.padding === 0 + ); +} + +// Apply the source options in the order the picture demands. The vignette always +// belongs on top of whatever counts as the picture: extending makes that the +// whole framed canvas, so it runs last; without extend the frame is only white +// margin, which the vignette must neither be centred on nor scaled against, so +// it runs on the picture first. +export function processSourceImage( + image: RgbaImage, + config: SourceConfig +): RgbaImage { + const vignette = (input: RgbaImage) => + config.vignette ? applyVignette(input, config) : input; + const frame = (input: RgbaImage) => + frameRgbaImage(input, { + aspectRatio: parseAspectRatio(config.aspectRatio), + padding: config.padding, + extend: config.extend, + }); + + return config.extend ? vignette(frame(image)) : frame(vignette(image)); +} + +// Produce the image actually fed to PINTR. With every source option at its +// default this is a passthrough so the rest of the pipeline is byte-for-byte +// unchanged. +export async function preprocessImage( + imgSrc: string, + config: SourceConfig +): Promise { + const srcCanvas = await getSourceCanvas(imgSrc); + + if (isIdentity(config)) { + return { src: imgSrc, width: srcCanvas.width, height: srcCanvas.height }; + } + + const srcCtx = srcCanvas.getContext('2d'); + if (!srcCtx) throw new Error("Failed to 'getContext2d'"); + const source = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height); + + const image = processSourceImage( + { width: source.width, height: source.height, rgba: source.data }, + config + ); + + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Failed to 'getContext2d'"); + ctx.putImageData( + new ImageData( + new Uint8ClampedArray(image.rgba), + image.width, + image.height + ), + 0, + 0 + ); + + return { src: canvas.toDataURL(), width: image.width, height: image.height }; +} diff --git a/lib/renderCoords.ts b/lib/renderCoords.ts index 0fffb06..8f86753 100644 --- a/lib/renderCoords.ts +++ b/lib/renderCoords.ts @@ -17,21 +17,26 @@ export function renderCoordsToCanvas( ctx.clearRect(0, 0, width, height); ctx.beginPath(); - if (singleLine && smoothingAmount > 0) { - const points = coords.map((coord) => coord[0]); - if (points.length) { - const segments = smoothControlPoints(points, smoothingAmount); - ctx.moveTo(points[0][0], points[0][1]); - for (const s of segments) { - ctx.bezierCurveTo( - s.cps[0], - s.cps[1], - s.cpe[0], - s.cpe[1], - s.point[0], - s.point[1] - ); + if (singleLine) { + if (smoothingAmount > 0) { + const points = coords.map((coord) => coord[0]); + if (points.length) { + const segments = smoothControlPoints(points, smoothingAmount); + ctx.moveTo(points[0][0], points[0][1]); + for (const s of segments) { + ctx.bezierCurveTo( + s.cps[0], + s.cps[1], + s.cpe[0], + s.cpe[1], + s.point[0], + s.point[1] + ); + } } + } else if (coords.length) { + ctx.moveTo(coords[0][0][0], coords[0][0][1]); + for (const [, to] of coords) ctx.lineTo(to[0], to[1]); } } else { for (const [from, to] of coords) { @@ -41,6 +46,7 @@ export function renderCoordsToCanvas( } ctx.lineWidth = strokeWidth; + ctx.lineJoin = 'round'; ctx.strokeStyle = '#000'; ctx.stroke(); } diff --git a/lib/smooth-svg.ts b/lib/smooth-svg.ts index 30ff5f3..39127ea 100644 --- a/lib/smooth-svg.ts +++ b/lib/smooth-svg.ts @@ -84,7 +84,7 @@ export function generateSmoothSvg( return ` + }" xmlns="http://www.w3.org/2000/svg" stroke="black" stroke-linejoin="round"> ${background}${path} `; diff --git a/lib/svg.ts b/lib/svg.ts index c25d14f..66f72bd 100644 --- a/lib/svg.ts +++ b/lib/svg.ts @@ -22,7 +22,7 @@ function generateMultiLineSvg( ) { return ` + }" xmlns="http://www.w3.org/2000/svg" stroke="black" stroke-width="${strokeWidth}" stroke-linejoin="round"> ${backgroundRect(size, whiteBackground)}${coords .map((coord) => { return ``; @@ -46,7 +46,7 @@ function generateSinglePolySvg( ) { return ` + }" xmlns="http://www.w3.org/2000/svg" stroke-linejoin="round"> ${backgroundRect(size, whiteBackground)} diff --git a/main.ts b/main.ts index f2a6c0a..155b438 100644 --- a/main.ts +++ b/main.ts @@ -1,36 +1,89 @@ import { pinterCreator } from './lib/PINTR'; import { generateSvg } from './lib/svg'; +import { coordsFileName, generateCoordsTxt } from './lib/coords-txt'; import { renderCoordsToCanvas } from './lib/renderCoords'; +import { preprocessImage } from './lib/preprocess'; import { clamp, debounce, throttle } from './lib/utils'; import type { Coord } from './lib/PINTR'; +import type { SourceConfig } from './lib/preprocess'; -export type configType = { +// The pipeline has three independent layers, and the config mirrors them so the +// "what needs redoing?" question is answered by which group changed rather than +// by a hand-maintained list of field comparisons. + +// Only affects the image handed to the algorithm: re-run pre-processing, reload. +export type { SourceConfig }; + +// Affects the algorithm but not the source image: restart the PINTR session. +// Structurally identical to the core's PintrConfig, so it is passed straight in. +export type GenerationConfig = { contrast: number; definition: number; - lines: number; singleLine: boolean; strokeWidth: number; +}; + +// Independent of both: never regenerates, only repaints (or trims the buffer). +export type RenderConfig = { + lines: number; smoothingAmount: number; - advancedOptions: boolean; transparentBackground: boolean; }; +export type configType = { + source: SourceConfig; + generation: GenerationConfig; + render: RenderConfig; + advancedOptions: boolean; +}; + +// Stroke width uses the same shape as Lines: the slider is a plain 0-100 track +// and the curve lives here, so the fine widths people actually use occupy most +// of the travel while the thick end stays reachable. +const STROKE_MIN = 0.5; +const STROKE_MAX = 20; +const STROKE_POWER = 2; +// Mirrors the #strokeWidth input's defaultValue; lands on the historical 1.5px. +const STROKE_SLIDER_DEFAULT = 40; + +function strokeWidthFromSlider(value: number): number { + const t = clamp(value, 0, 100) / 100; + const width = + STROKE_MIN + (STROKE_MAX - STROKE_MIN) * Math.pow(t, STROKE_POWER); + return Math.round(width * 100) / 100; +} + const DEFAULT_IMG = '/pintr/test.jpg'; let CONFIG: configType = { - contrast: 50, - definition: 50, - lines: 50, - singleLine: true, - strokeWidth: 1, - smoothingAmount: 0, + source: { + aspectRatio: '', + padding: 0, + extend: false, + vignette: false, + vignetteDistance: 50, + vignetteHardness: 50, + vignetteSquare: false, + }, + generation: { + contrast: 50, + definition: 50, + singleLine: true, + strokeWidth: strokeWidthFromSlider(STROKE_SLIDER_DEFAULT), + }, + render: { + lines: 50, + smoothingAmount: 0, + transparentBackground: true, + }, advancedOptions: false, - transparentBackground: true, }; +type Size = { width: number; height: number }; + let GLOBAL: { currentImgSrc: string; // `buffer` holds every line ever drawn for the current image + generation @@ -43,6 +96,14 @@ let GLOBAL: { height: number; // Bumped on every new image so a superseded instance's late callbacks are ignored. drawToken: number; + // The pre-processed image actually fed to PINTR (equals currentImgSrc when + // every source option is at its default), plus whether the Source preview + // shows it. Both sizes are kept so the preview can use the right aspect for + // whichever of the two it is displaying. + processedImgSrc: string; + showPreprocessed: boolean; + sourceSize: Size; + processedSize: Size; } = { currentImgSrc: DEFAULT_IMG, coords: [], @@ -51,6 +112,10 @@ let GLOBAL: { width: 512, height: 512, drawToken: 0, + processedImgSrc: DEFAULT_IMG, + showPreprocessed: false, + sourceSize: { width: 512, height: 512 }, + processedSize: { width: 512, height: 512 }, }; // The single PINTR instance for the current image. Generation-param changes @@ -64,6 +129,12 @@ function getDrawCtx(): CanvasRenderingContext2D | null { return canvasDrawEl ? canvasDrawEl.getContext('2d') : null; } +// Renderers straddle two layers: the stroke geometry comes from the generation +// config, the styling from the render config. +function renderOptions() { + return { ...CONFIG.generation, ...CONFIG.render }; +} + // The Lines slider (0-100) maps directly to a target line count via a power // curve — no image-lightness or stroke-width scaling, so the slider value alone // decides how many lines are drawn. Left end is exactly LINES_MIN, right end is @@ -75,7 +146,7 @@ const LINES_POWER = 2; function desiredLineCount(): number { if (!PINTR) return 0; - const t = clamp(CONFIG.lines, 0, 100) / 100; + const t = clamp(CONFIG.render.lines, 0, 100) / 100; return Math.round( LINES_MIN + (LINES_MAX - LINES_MIN) * Math.pow(t, LINES_POWER) ); @@ -86,8 +157,13 @@ function desiredLineCount(): number { // displayed lines, not a live in-progress draw). function showFromBuffer(count: number) { GLOBAL.coords = GLOBAL.buffer.slice(0, count); + repaint(); +} + +// Repaint what is already generated. Nothing here touches PINTR. +function repaint() { const ctx = getDrawCtx(); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); + if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, renderOptions()); } // Fresh drawing: reset the buffer and (re)start PINTR with the current @@ -98,12 +174,7 @@ function restartDrawing() { GLOBAL.coords = []; const desired = desiredLineCount(); GLOBAL.displayedCount = desired; - PINTR.start({ - contrast: CONFIG.contrast, - definition: CONFIG.definition, - singleLine: CONFIG.singleLine, - strokeWidth: CONFIG.strokeWidth, - }); + PINTR.start(CONFIG.generation); PINTR.requestLines(desired); } @@ -128,16 +199,54 @@ function applyLines() { const ctx = getDrawCtx(); if (ctx) renderCoordsToCanvas(ctx, GLOBAL.buffer, { - ...CONFIG, + ...renderOptions(), smoothingAmount: 0, }); PINTR.requestLines(desired); } } -// Create a PINTR instance for a new image and kick off the first drawing. +// The coordinate export is in the drawing's own pixel space, which the aspect +// ratio and padding options change. Show the live extent next to the button so +// the numbers in the file are never a guess. +function updateCoordsSize() { + const coordsSizeEl = document.querySelector('#coordsSize'); + if (coordsSizeEl) { + coordsSizeEl.textContent = `${GLOBAL.width}\u00d7${GLOBAL.height}`; + } +} + +// Show the source preview, honouring the Source / Pre-processing toggle. Each of +// the two images has its own aspect, which the aspect ratio option can make very +// different from the other's. +function updateSourcePreview() { + const srcImgEl: HTMLElement | null = document.querySelector('#srcImg'); + if (srcImgEl) { + const src = GLOBAL.showPreprocessed + ? GLOBAL.processedImgSrc + : GLOBAL.currentImgSrc; + const size = GLOBAL.showPreprocessed + ? GLOBAL.processedSize + : GLOBAL.sourceSize; + srcImgEl.style.backgroundImage = `url("${src}")`; + srcImgEl.style.aspectRatio = String(size.width / size.height); + } + + const labelEl: HTMLElement | null = document.querySelector( + '.srcimage-container .label' + ); + if (labelEl) { + labelEl.textContent = GLOBAL.showPreprocessed + ? 'Source - Pre processing' + : 'Source'; + } +} + +// Create a PINTR instance for a new image and kick off the first drawing. The +// image is run through source pre-processing first; PINTR draws the result. async function loadImage(imgSrc: string) { const myToken = ++GLOBAL.drawToken; + GLOBAL.currentImgSrc = imgSrc; // Stop the previous instance so it doesn't keep drawing to the shared canvas. if (PINTR) PINTR.stop(); @@ -149,7 +258,13 @@ async function loadImage(imgSrc: string) { throw new Error(); } - const instance = await pinterCreator(imgSrc, { + const processed = await preprocessImage(imgSrc, CONFIG.source); + // A newer image started while this one was pre-processing — discard it. + if (myToken !== GLOBAL.drawToken) return; + GLOBAL.processedImgSrc = processed.src; + GLOBAL.processedSize = { width: processed.width, height: processed.height }; + + const instance = await pinterCreator(processed.src, { canvasDrawEl, onLoad({ width, height }) { if (myToken !== GLOBAL.drawToken) return; @@ -158,6 +273,7 @@ async function loadImage(imgSrc: string) { GLOBAL.width = width; GLOBAL.height = height; + updateCoordsSize(); }, onProgress({ coords, done }) { if (myToken !== GLOBAL.drawToken) return; @@ -166,12 +282,9 @@ async function loadImage(imgSrc: string) { const shown = Math.min(GLOBAL.buffer.length, GLOBAL.displayedCount); GLOBAL.coords = GLOBAL.buffer.slice(0, shown); - // The live draw is straight lines; once it reaches the target, redraw as a - // smooth curve so the smoothing slider is visible on screen. - if (done && CONFIG.smoothingAmount > 0 && CONFIG.singleLine) { - const ctx = canvasDrawEl.getContext('2d'); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); - } + // Repaint the completed drawing as one connected path in single-line mode + // so canvas joins match the SVG (and apply smoothing when requested). + if (done && CONFIG.generation.singleLine) repaint(); }, }); @@ -182,10 +295,7 @@ async function loadImage(imgSrc: string) { } PINTR = instance; - const srcImgEl: HTMLImageElement | null = document.querySelector('#srcImg'); - if (srcImgEl) { - srcImgEl.style.backgroundImage = `url("${imgSrc}")`; - } + updateSourcePreview(); restartDrawing(); } @@ -204,14 +314,26 @@ function readFile(evt: Event) { const FR = new FileReader(); FR.addEventListener('load', function (e) { if (!e || !e.target) return; - GLOBAL.currentImgSrc = String(e.target.result); - loadImage(String(e.target.result)); + selectImage(String(e.target.result)); }); FR.readAsDataURL(file); } } +// Adopt a newly chosen image: measure it (the Source preview needs its real +// aspect) and draw it. +function selectImage(imgSrc: string) { + const probe = new window.Image(); + probe.onload = () => { + GLOBAL.sourceSize = { width: probe.width, height: probe.height }; + updateSourcePreview(); + }; + probe.src = imgSrc; + + loadImage(imgSrc); +} + function getInputNumber(selector: string): number { const inputEl: HTMLInputElement | null = document.querySelector(selector); return inputEl ? Number(inputEl.value) : 0; @@ -222,58 +344,115 @@ function getInputBoolean(selector: string): boolean { return inputEl ? Boolean(Number(inputEl.value)) : false; } +function getInputString(selector: string): string { + const inputEl: HTMLSelectElement | HTMLInputElement | null = + document.querySelector(selector); + return inputEl ? inputEl.value : ''; +} + function readConfigFromInputs(): configType { return { - lines: getInputNumber('#lines'), - singleLine: getInputBoolean('#singleLine'), - contrast: getInputNumber('#contrast'), - definition: getInputNumber('#definition'), - strokeWidth: getInputNumber('#strokeWidth'), - smoothingAmount: getInputNumber('#smoothingAmount'), + source: { + aspectRatio: getInputString('#aspectRatio'), + padding: getInputNumber('#padding'), + extend: getInputBoolean('#extendImage'), + vignette: getInputBoolean('#vignette'), + vignetteDistance: getInputNumber('#vignetteDistance'), + vignetteHardness: getInputNumber('#vignetteHardness'), + vignetteSquare: getInputBoolean('#vignetteSquare'), + }, + generation: { + contrast: getInputNumber('#contrast'), + definition: getInputNumber('#definition'), + singleLine: getInputBoolean('#singleLine'), + strokeWidth: strokeWidthFromSlider(getInputNumber('#strokeWidth')), + }, + render: { + lines: getInputNumber('#lines'), + smoothingAmount: getInputNumber('#smoothingAmount'), + transparentBackground: getInputBoolean('#transparentBackground'), + }, advancedOptions: getInputBoolean('#advancedOptions'), - transparentBackground: getInputBoolean('#transparentBackground'), }; } -// A control changed. Generation params (definition/contrast/single line/stroke -// width) restart the drawing; lines is incremental; the rest are UI/export -// only and need no redraw. +function changed(a: T, b: T): boolean { + return (Object.keys(a) as (keyof T)[]).some((key) => a[key] !== b[key]); +} + +// Shows or hides an optional panel. A panel that is half of a folder also marks +// its wrapper, so the panel and its toggle row draw as one open folder. +function syncOptionalPanel(panelSelector: string, open: boolean) { + const panelEl = document.querySelector(panelSelector) as HTMLElement | null; + if (!panelEl) return; + + panelEl.style.display = open ? 'block' : 'none'; + panelEl.closest('.folder')?.classList.toggle('-open', open); +} + +function syncOptionalSections() { + syncOptionalPanel('.advanced-options--container', CONFIG.advancedOptions); + syncOptionalPanel('.vignette-options--container', CONFIG.source.vignette); + + syncExtendImage(); + updateSmoothingWarning(); +} + +// Extending only fills margin the frame adds around the picture, so with no +// padding and the original aspect ratio there is nothing for it to do: disable +// the toggle instead of leaving it inert. +function syncExtendImage() { + const extendImageEl = document.querySelector( + '#extendImage' + ) as HTMLInputElement | null; + if (!extendImageEl) return; + + const framed = CONFIG.source.padding > 0 || CONFIG.source.aspectRatio !== ''; + extendImageEl.disabled = !framed; + extendImageEl + .closest('.input-container') + ?.classList.toggle('-disabled', !framed); +} + +// A control changed. Which layer it belongs to decides how much work to redo: +// source options rebuild the image PINTR draws, generation options restart the +// session, and render options only touch what is already generated. function onControlChange() { const prev = CONFIG; CONFIG = readConfigFromInputs(); - const advancedOptionsContainerEl = document.querySelector( - '.advanced-options--container' - ) as HTMLElement; - advancedOptionsContainerEl.style.display = CONFIG.advancedOptions - ? 'block' - : 'none'; - - updateSmoothingWarning(); + syncOptionalSections(); - const generationChanged = - CONFIG.definition !== prev.definition || - CONFIG.contrast !== prev.contrast || - CONFIG.singleLine !== prev.singleLine || - CONFIG.strokeWidth !== prev.strokeWidth; + // Opening Advanced switches the Source preview to the pre-processed image, + // since that is what the advanced options act on. Clicking the preview still + // flips it back. + if (CONFIG.advancedOptions && !prev.advancedOptions) { + GLOBAL.showPreprocessed = true; + updateSourcePreview(); + } - if (generationChanged) { + if (changed(CONFIG.source, prev.source)) { + loadImage(GLOBAL.currentImgSrc); + } else if (changed(CONFIG.generation, prev.generation)) { restartDrawing(); - } else if (CONFIG.lines !== prev.lines) { + } else if (CONFIG.render.lines !== prev.render.lines) { applyLines(); + } else { + repaint(); } } // Smoothing only applies to single-line drawings; warn when it won't take effect. function updateSmoothingWarning() { - const smoothingAmount = getInputNumber('#smoothingAmount'); - const singleLine = getInputBoolean('#singleLine'); - const smoothSvgContainerWarningEl = document.querySelector( '.experimental--smooth-svg--container--warning' - ) as HTMLElement; - smoothSvgContainerWarningEl.style.display = - smoothingAmount > 0 && !singleLine ? 'block' : 'none'; + ) as HTMLElement | null; + if (smoothSvgContainerWarningEl) { + smoothSvgContainerWarningEl.style.display = + CONFIG.render.smoothingAmount > 0 && !CONFIG.generation.singleLine + ? 'block' + : 'none'; + } } let count = 0; @@ -291,8 +470,7 @@ function onDrop(ev: DragEvent) { return; } - GLOBAL.currentImgSrc = String(e.target.result); - loadImage(String(e.target.result)); + selectImage(String(e.target.result)); document.body.classList.remove('-dragging'); count = 0; }); @@ -327,124 +505,182 @@ function onDragLeave(evt: DragEvent) { } } -// ADD LISTENERS -const appEl = document.querySelector('.app') as HTMLElement; -appEl.addEventListener('drop', onDrop); -appEl.addEventListener('dragover', onDragOver); -appEl.addEventListener('dragenter', onDragEnter); -appEl.addEventListener('dragleave', onDragLeave); - -const inputImageFileEl = document.querySelector( - '#inputImageFile' -) as HTMLInputElement; - -inputImageFileEl.addEventListener('change', readFile); - -const inputImageButtonEl = document.querySelector( - '#inputImageButton' -) as HTMLElement; -inputImageButtonEl.addEventListener('click', () => { - inputImageFileEl.click(); -}); - // React to each control as fast as it's cheap to. Lines only trims or extends // the already-computed line buffer, so it's effectively free: track the slider -// live (`input`) with a tiny throttle. Contrast/definition regenerate the whole -// drawing, so they react only on release (`change`) with a heavier throttle; -// everything else keeps the default. -const HEAVY_CONTROLS = new Set(['contrast', 'definition']); - -document - .querySelectorAll('[data-start-drawing]') - .forEach((input) => { - if (input.id === 'lines') { - // Throttle (not debounce) so it tracks the slider live during the drag - // instead of only firing on release. - input.addEventListener('input', throttle(onControlChange, 16)); - } else { - const delay = HEAVY_CONTROLS.has(input.id) ? 120 : 32; - input.addEventListener('change', debounce(onControlChange, delay)); - } +// live (`input`) with a tiny throttle. Smoothing only repaints, so it can track +// live too. Controls that rerun the pixel pass or regenerate the whole drawing +// react only on release (`change`) with a heavier throttle. +const LIVE_CONTROLS = new Set(['lines', 'smoothingAmount']); +const HEAVY_CONTROLS = new Set([ + 'contrast', + 'definition', + 'padding', + 'vignetteDistance', + 'vignetteHardness', +]); + +/** + * Wire the drawing UI to the currently mounted page. Next.js keeps this module + * cached across client-side navigation, so initialization cannot happen as a + * top-level side effect: Privacy/Support navigation replaces all of these DOM + * nodes while retaining the module instance. + */ +export function initializePintrApp(): () => void { + const abortController = new AbortController(); + const { signal } = abortController; + count = 0; + + const appEl = document.querySelector('.app') as HTMLElement; + appEl.addEventListener('drop', onDrop, { signal }); + appEl.addEventListener('dragover', onDragOver, { signal }); + appEl.addEventListener('dragenter', onDragEnter, { signal }); + appEl.addEventListener('dragleave', onDragLeave, { signal }); + + const inputImageFileEl = document.querySelector( + '#inputImageFile' + ) as HTMLInputElement; + inputImageFileEl.addEventListener('change', readFile, { signal }); + + const inputImageButtonEl = document.querySelector( + '#inputImageButton' + ) as HTMLElement; + inputImageButtonEl.addEventListener('click', () => inputImageFileEl.click(), { + signal, }); -// Toggles are 0/1 range inputs; let a tap/click anywhere flip them instead of -// requiring the user to drag the thumb to the other end. -document - .querySelectorAll('input[type="range"].toggle') - .forEach((toggle) => { - toggle.addEventListener('pointerdown', (evt) => { - evt.preventDefault(); - toggle.value = Number(toggle.value) ? '0' : '1'; - toggle.dispatchEvent(new Event('change', { bubbles: true })); + document + .querySelectorAll('[data-start-drawing]') + .forEach((input) => { + if (LIVE_CONTROLS.has(input.id)) { + // Throttle (not debounce) so it tracks the slider live during the drag + // instead of only firing on release. + input.addEventListener('input', throttle(onControlChange, 16), { + signal, + }); + } else { + const delay = HEAVY_CONTROLS.has(input.id) ? 120 : 32; + input.addEventListener('change', debounce(onControlChange, delay), { + signal, + }); + } }); - }); -const smoothingAmountEl = document.querySelector( - '#smoothingAmount' -) as HTMLInputElement; -smoothingAmountEl.addEventListener( - 'input', - throttle(() => { - CONFIG.smoothingAmount = getInputNumber('#smoothingAmount'); - updateSmoothingWarning(); - - // Redraw the existing drawing (smooth or straight) without regenerating lines. - const canvasDrawEl = document.querySelector( - 'canvas#draw' - ) as HTMLCanvasElement; - const ctx = canvasDrawEl.getContext('2d'); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); - }, 32) -); - -const downloadEl = document.querySelector('#download') as HTMLButtonElement; -downloadEl.addEventListener('click', () => { - const link = document.createElement('a'); - const canvasDrawEl = document.querySelector( - 'canvas#draw' - ) as HTMLCanvasElement; - - link.download = 'PINTR.png'; - - if (CONFIG.advancedOptions && !CONFIG.transparentBackground) { - const tmpCanvas = document.createElement('canvas'); - tmpCanvas.width = canvasDrawEl.width; - tmpCanvas.height = canvasDrawEl.height; - const tmpCtx = tmpCanvas.getContext('2d') as CanvasRenderingContext2D; - tmpCtx.fillStyle = '#fff'; - tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); - tmpCtx.drawImage(canvasDrawEl, 0, 0); - link.href = tmpCanvas.toDataURL(); - } else { - link.href = canvasDrawEl.toDataURL(); - } + // Toggles are 0/1 range inputs; let a tap/click anywhere flip them instead of + // requiring the user to drag the thumb to the other end. + document + .querySelectorAll('input[type="range"].toggle') + .forEach((toggle) => { + toggle.addEventListener( + 'pointerdown', + (evt) => { + evt.preventDefault(); + toggle.value = Number(toggle.value) ? '0' : '1'; + toggle.dispatchEvent(new Event('change', { bubbles: true })); + }, + { signal } + ); + }); - link.click(); -}); - -const downloadSvgEl = document.querySelector( - '#downloadSvg' -) as HTMLButtonElement; -downloadSvgEl.addEventListener('click', () => { - const link = document.createElement('a'); - link.download = 'PINTR.svg'; - const svgData = generateSvg(GLOBAL.coords, { - ...CONFIG, - size: [GLOBAL.width, GLOBAL.height], - whiteBackground: CONFIG.advancedOptions && !CONFIG.transparentBackground, - }); - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); - const svgUrl = URL.createObjectURL(svgBlob); - link.href = svgUrl; - link.click(); - setTimeout(() => URL.revokeObjectURL(svgUrl), 1000); -}); - -// Initial bootstrap: sync CONFIG/UI from the inputs' default values, then load -// the default image and start drawing. -CONFIG = readConfigFromInputs(); -( - document.querySelector('.advanced-options--container') as HTMLElement -).style.display = CONFIG.advancedOptions ? 'block' : 'none'; -updateSmoothingWarning(); -loadImage(GLOBAL.currentImgSrc); + // Click the Source preview to toggle between the original image and the + // pre-processed image that PINTR actually draws. + const srcImageContainerEl = document.querySelector( + '.srcimage-container' + ) as HTMLElement | null; + srcImageContainerEl?.addEventListener( + 'click', + () => { + GLOBAL.showPreprocessed = !GLOBAL.showPreprocessed; + updateSourcePreview(); + }, + { signal } + ); + + const downloadEl = document.querySelector('#download') as HTMLButtonElement; + downloadEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + const canvasDrawEl = document.querySelector( + 'canvas#draw' + ) as HTMLCanvasElement; + + link.download = 'PINTR.png'; + + if (!CONFIG.render.transparentBackground) { + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = canvasDrawEl.width; + tmpCanvas.height = canvasDrawEl.height; + const tmpCtx = tmpCanvas.getContext('2d') as CanvasRenderingContext2D; + tmpCtx.fillStyle = '#fff'; + tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); + tmpCtx.drawImage(canvasDrawEl, 0, 0); + link.href = tmpCanvas.toDataURL(); + } else { + link.href = canvasDrawEl.toDataURL(); + } + + link.click(); + }, + { signal } + ); + + const downloadSvgEl = document.querySelector( + '#downloadSvg' + ) as HTMLButtonElement; + downloadSvgEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + link.download = 'PINTR.svg'; + const svgData = generateSvg(GLOBAL.coords, { + ...renderOptions(), + size: [GLOBAL.width, GLOBAL.height], + whiteBackground: !CONFIG.render.transparentBackground, + }); + const svgBlob = new Blob([svgData], { + type: 'image/svg+xml;charset=utf-8', + }); + const svgUrl = URL.createObjectURL(svgBlob); + link.href = svgUrl; + link.click(); + setTimeout(() => URL.revokeObjectURL(svgUrl), 1000); + }, + { signal } + ); + + const downloadTxtEl = document.querySelector( + '#downloadTxt' + ) as HTMLButtonElement; + downloadTxtEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + link.download = coordsFileName([GLOBAL.width, GLOBAL.height]); + const txtData = generateCoordsTxt(GLOBAL.coords); + const txtBlob = new Blob([txtData], { type: 'text/plain;charset=utf-8' }); + const txtUrl = URL.createObjectURL(txtBlob); + link.href = txtUrl; + link.click(); + setTimeout(() => URL.revokeObjectURL(txtUrl), 1000); + }, + { signal } + ); + + // Sync CONFIG/UI from the fresh inputs, then attach the drawing engine to the + // newly mounted canvas. Keep the selected image across client navigation. + CONFIG = readConfigFromInputs(); + syncOptionalSections(); + selectImage(GLOBAL.currentImgSrc); + + return () => { + abortController.abort(); + document.body.classList.remove('-dragging'); + count = 0; + + // Invalidate async image work and stop callbacks from drawing into the + // detached canvas after navigation. + GLOBAL.drawToken++; + PINTR?.stop(); + PINTR = null; + }; +} diff --git a/package.json b/package.json index e7b14ee..54ed886 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,22 @@ "dev": "next dev", "build": "next build", "start": "next start", - "type-check": "tsc --noEmit -p tsconfig.json" + "type-check": "tsc --noEmit -p tsconfig.json", + "test": "node --import ./tests/register.mjs --test tests/*.test.ts" }, "devDependencies": { - "@types/node": "26.0.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "eslint": "^10.5.0", - "typescript": "^6.0.3" + "@types/node": "26.4.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "eslint": "^10.9.1", + "typescript": "^7.0.2" }, "dependencies": { - "@next/third-parties": "^16.2.9", + "@next/third-parties": "^16.3.4", "canvas-image-utils": "^2.5.0", - "next": "^16.2.9", - "react": "^19.2.7", - "react-dom": "^19.2.7" + "next": "^16.3.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "author": "Javier Bórquez ", "license": "BSD-3-Clause" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85409f..28333aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,44 +9,44 @@ importers: .: dependencies: '@next/third-parties': - specifier: ^16.2.9 - version: 16.2.9(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + specifier: ^16.3.4 + version: 16.3.4(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) canvas-image-utils: specifier: ^2.5.0 version: 2.5.0 next: - specifier: ^16.2.9 - version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^16.3.4 + version: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: - specifier: ^19.2.7 - version: 19.2.7 + specifier: ^19.2.8 + version: 19.2.8 react-dom: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) devDependencies: '@types/node': - specifier: 26.0.1 - version: 26.0.1 + specifier: 26.4.0 + version: 26.4.0 '@types/react': - specifier: ^19.2.17 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) eslint: - specifier: ^10.5.0 - version: 10.5.0 + specifier: ^10.9.1 + version: 10.9.1 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^7.0.2 + version: 7.0.2 packages: - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -59,8 +59,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -99,218 +99,227 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@next/third-parties@16.2.9': - resolution: {integrity: sha512-JZPGQRN8eQs2WMfBXOf6Zjrma+JIN0KyA24MvmIa3bUI4BwTaJ1UlxmYVc5ri6l30LQ8fPv6Kmx20447hCltrg==} + '@next/third-parties@16.3.4': + resolution: {integrity: sha512-DjyyOoBHPHYFgQ0l9VXJFWMeO5FvnFP+bXM9ZC0eGYluVwpNIvMjDPhDhM/VlhK+VK423G+8LMD+y/xwEKAi+w==} peerDependencies: next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0 react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -321,24 +330,144 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -349,17 +478,17 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.40: - resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} canvas-image-utils@2.5.0: resolution: {integrity: sha512-BMtKO3/uvdOfY42wbOYwgWZl3oIwgM1WJ6nPNA8EnGa6E2uhCcKxBKIkpYqDpkzoLDFY9oXk6V5OX5m4FbRJfg==} @@ -406,8 +535,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.5.0: - resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -457,8 +586,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -503,23 +632,23 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -562,8 +691,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -574,13 +703,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.7 + react: ^19.2.8 - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} scheduler@0.27.0: @@ -591,9 +720,14 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -630,9 +764,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true undici-types@8.3.0: @@ -656,14 +790,14 @@ packages: snapshots: - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1)': dependencies: - eslint: 10.5.0 + eslint: 10.9.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -672,11 +806,11 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -710,133 +844,143 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.1 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-x64@0.35.4': optional: true - '@next/env@16.2.9': {} + '@next/env@16.3.4': {} - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.3.4': optional: true - '@next/third-parties@16.2.9(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@next/third-parties@16.3.4(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react: 19.2.7 + next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 third-party-capital: 1.0.20 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -846,23 +990,83 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.0.1': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 - acorn-jsx@5.3.2(acorn@8.17.0): + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} ajv@6.15.0: dependencies: @@ -873,13 +1077,13 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.20: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001810: {} canvas-image-utils@2.5.0: {} @@ -915,12 +1119,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.5.0: + eslint@10.9.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 @@ -944,7 +1148,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -952,8 +1156,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -985,10 +1189,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} glob-parent@6.0.2: dependencies: @@ -1025,38 +1229,39 @@ snapshots: dependencies: p-locate: 5.0.0 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} - nanoid@3.3.15: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} - next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.2.9 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001799 - postcss: 8.4.31 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - styled-jsx: 5.1.6(react@19.2.7) + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@26.4.0) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros optionator@0.9.4: @@ -1082,9 +1287,9 @@ snapshots: picocolors@1.1.1: {} - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1092,48 +1297,50 @@ snapshots: punycode@2.3.1: {} - react-dom@19.2.7(react@19.2.7): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 scheduler: 0.27.0 - react@19.2.7: {} + react@19.2.8: {} scheduler@0.27.0: {} semver@7.8.5: optional: true - sharp@0.34.5: + sharp@0.35.4(@types/node@26.4.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.4.0 optional: true shebang-command@2.0.0: @@ -1144,10 +1351,10 @@ snapshots: source-map-js@1.2.1: {} - styled-jsx@5.1.6(react@19.2.7): + styled-jsx@5.1.6(react@19.2.8): dependencies: client-only: 0.0.1 - react: 19.2.7 + react: 19.2.8 third-party-capital@1.0.20: {} @@ -1157,7 +1364,28 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 undici-types@8.3.0: {} diff --git a/public/fonts/Brutalita-Regular.woff2 b/public/fonts/Brutalita-Regular.woff2 deleted file mode 100644 index 2df5cfd3be2ad3455785f3c89c94c416c572d560..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3408 zcmV-W4X^TdPew8T0RR9101Z$84FCWD05n7Z01Wm30RR9100000000000000000000 z0000#Mn+Uk92y=5U;u(r5eN#_5Wpl0fk*%WHUcCAfldS<1%?R+fEZe7lU_Krz)=hmK?hXtPh z_jcdTHZ9^Xp)lbR%P^FLqkp(I@Ws6{d6Rn)=~Q|K(!BG*gt7qm`ceRD1Dc&$#ZRa* zgE@Ti;QMQ8FWo)Q{@QqoU;u9gBHvp{PxeZyD;oLyORxL1zBy}^V(Zs}(h3Y~4OLnZ z3JMS6AvhFd0_kj27(P@umnMCwtx;B)|2C7SOK=l5fB?*$goE-Uu^rP)vZ^pQ? z;$44)f%6jZNJet)l0P-b9(zdA+wwv9SaQ?*`3}GQPE^Mv;J&Rg$Sr!O%R{om{#PR{ ziTkbw%2Me_abK#j(u(Lyd*rpib2uKUo%sAk_%_&WV5&)KSg0jqzw3SwX2CEYhAX3g zK-rG#`9TO#oTOP^lvUldT|bP|ysX=PoVyK;P0dzYOKV$uM`xGa;dC)9#|v&zlDm6) z`}zma?qB%QZXFfwed85>VsDNvZlP+slyb$TY~OHC3brTqQjQ~Rd4k#Y!k#9GL2$85 z>o&GFIQ8c>1rbe!m)-wYA3JA^B9_VX*|*So_k0=|QwR-5xDqiDMFKc^n7&asU9V_7 z2MAKqdIT}wZQ)rDIzgI(W5@;(MIV`wGl{~$Uq#V4_At035adUGWUrNu*SD1iA{gTN z2}wyA8EI(=3Gwl%sp;v70*;G|%Selfi6Nq+qf^skWBZ_0pr;Vv(xrfujaY+<6gUG{ zi97``L$Oc*_ZkXF?1*0sv~ag!Do!QM!XnnV>|CcgM2Z0yDf4SRYfG1KaW-&DHUY_~ zfFWre*N9Zv6djFNCy?9aSrAg`je?UBS|3WbFi=xvNQSk5vWO*scht-Zfd+I6C(SOPFbZq-p`ckNobuf80#V(VkgDm zK51H`MXZ&|?0aDhT;$dt3&zke8rDbI`vy+-$=Aoqx@ZjELzFv8fPx8nEZV6pXc#&$ zyaSuUg@Ccsh&jO`2&Ba5kf;NWzQ+LN7QZHrls5)B;|f0ny12uMm%!(e%z}ok!_npC z2(jh)ipjHvj6qN}7%pzU6~Y`Z?SW%)znp;DI-ZF$oIM&VEiOYK;$ZM&Y^#8I@;Hqw z3`wefuf&=tHf>05Mk|?`$@W~2t47=?9W{ZgPmYktgaZHIfIj#xiMV zcp!P{s;fayJZT>Lz=#rM3e;cWCeZ_dln6ZcM1S?<&6c%r=x|&d85lUZtPci+@W-o9 zGvIQd<+ZNxzqqp&cmv6yN#%OPlVV6x!np}w4k6`5D%UC`6=l%MTcSp*2Th;MqeV&S5e9Vw4k{%*?bM*Hoc813@5f zk5-v}PGcO!qEi&+o`8YI5WUz6YFQV9CZ&LFCX$(b32R2XkEn2dn}s=HG?>LYlN4>b z@3LMLsOl<;lk;e|8Mc!=OV-=c5_W^YL#De9IN>J^G|6WyToTSIMo;X0XlS%poJ_-CIcgcaT!W}N|GZE3x9r~gj34Mk~m1V(#qXF(8nql6!Qf| zXc_-Eq~(r+(A;$wyN(@)t$l9EPKY$zlNIZk!)t5B@n4wVi*(a zjQY#{seB{eTc7RjSz2-U8aX3snKjnUD#M@QXZU)m1EjAxePqwh`OZ)q9kfvOsyl<{ z2E@Sc&xJexIr~1!p9`m+&EA+lKX?!YG4X&6I(=D$V>vbRtZ$hAx;^wV*s6@B+M{|( z2(DZodXD|{DZ??+bYKh=hh9DLK=ZYzu+PI-(a<0_uwBVuU(=Uz;3hT?)*n686Lm=> zrqwjy=ht~S15>HW1o>sg%MJA%#&a498Euey5dUQnw{sH%_&7j3;_uo<2ULi;ov$#= zWQUL9nK`m&IU;QEY&Rd-eY!~nQVT)Hl%fKt(ei<|84ajts2VAu9PU`p)JgWcH{GJ; z0}Ts~x>-Hp$oecpRuzYRC)*%EX@itOLvJ8fy%;U_B?h0esm!@8D|+GB9E>kvZik3{{o5Y~E5I9B;lhuL zM3Urdj=kI&Ecj+>aHo1YJvzqCdZR5TH+sqk;EA4*RnMNfcL%nhuQ*Tnq&M(e~iw`_KdZA9pRHKmgI*hQ_NT0EpmfdK;cBPqMH;(qdx<__@@TU<6_Z^-PpPc1p6hfkEy;)To zl!1k+QagV*(_AWquyOu`0Ts2~_RO}dW=IaaSn$bQ8Apr#9X7AJb7F-! zD7IFak+j96dQ~f7z>+WRvux{7EIqXPR9?uZzBPR+EoLxIYU}Qs@leU@iqtr6)T$xX zMly4`soqTwC6ZHmQ`OBKR`5xfCvM1*Kc8vC5&_bb6L)(3+}uxf_LDe7xqG)1dVE>| zSJ#(NDZQTWT+W-g>|;P&0uL6;LB-_=p|feyz#AskS(Yc~W?7!Vy)>==*UzZ7x^yoP z1j|(P>2}qt9%^v;l5Gz&pk+hqqvJ~oQ*Q+AWlRgY*-PR%w?>GU<8K?akart1(6&VJ z-oZ%P3TM8iKk>}Pc+C4k;q*-?7869^>7;FEjJqCs*v}Xaq+Vb4P;@4S=9&kim!GWG zcC;LJ23&$Bm|vsA%BKdovI$c+hD0zVER2gWPH8dt6S6?$b^C5On3)vODCq|G)k5y# zT?{||^oNAdM&F0Xs{c{ye*PJe@7OMu|6C=ch~f1AT*suHP3nJG%@fhhQ!&UhG0t-_%L}pWRgCGr zf6b5+nV=D9&r&NaH(=Vt=abOhwPN|e^u+tS5jr+v%nH*6EQS7eH(;)+xH?Tdosk_+ zoA{^AYrA#NjZ|yd)Y3&}k2T9@f&NVS&CGd)-rEf?ugJ)^nKRjt*xi{bF_ z2?&XZNl3}aDJZEB5Rs5kQ29WhFgO}I1|}9Zj#8ybmmyP@Y&mk}$wxD@C{U>Qk27%UD?;3ksDy7eTBn1qx}4}xhP{{?dj mN-Am^S~_|LMkZz!RyKAHPGq`yzOI)(`dP<%Hn5QZ0001yrG2mf diff --git a/tests/coords-txt.test.ts b/tests/coords-txt.test.ts new file mode 100644 index 0000000..295d688 --- /dev/null +++ b/tests/coords-txt.test.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + coordsFileName, + generateCoordsTxt, + segmentsToPaths, +} from '../lib/coords-txt'; +import type { Coord } from '../lib/PINTR'; + +const SIZE: [number, number] = [1080, 1350]; + +// A chain, as single-line mode emits it: each segment starts where the last ended. +function chain(points: Coord[]): [Coord, Coord][] { + const segments: [Coord, Coord][] = []; + for (let i = 1; i < points.length; i++) { + segments.push([points[i - 1], points[i]]); + } + return segments; +} + +test('a chained segment list collapses to one path keeping every point', () => { + const points: Coord[] = [ + [0, 0], + [10, 20], + [30, 40], + [50, 60], + ]; + + const paths = segmentsToPaths(chain(points)); + + assert.equal(paths.length, 1); + assert.equal(paths[0].length, points.length); + assert.deepEqual(paths[0], points); +}); + +test('a disjoint segment list yields one two-point path per segment', () => { + const segments: [Coord, Coord][] = [ + [ + [0, 0], + [1, 1], + ], + [ + [10, 10], + [11, 11], + ], + [ + [20, 20], + [21, 21], + ], + ]; + + const paths = segmentsToPaths(segments); + + assert.equal(paths.length, segments.length); + for (const path of paths) assert.equal(path.length, 2); + assert.deepEqual(paths, segments); +}); + +test('a mixed list splits at exactly the discontinuities', () => { + const segments: [Coord, Coord][] = [ + ...chain([ + [0, 0], + [1, 1], + [2, 2], + ]), + // Jump: starts somewhere other than [2, 2]. + ...chain([ + [50, 50], + [51, 51], + ]), + // Continues from [51, 51], so it stays in the same path. + [ + [51, 51], + [52, 52], + ], + ]; + + assert.deepEqual(segmentsToPaths(segments), [ + [ + [0, 0], + [1, 1], + [2, 2], + ], + [ + [50, 50], + [51, 51], + [52, 52], + ], + ]); +}); + +test('paths are separated by a blank line and rows are bare coordinates', () => { + const segments: [Coord, Coord][] = [ + ...chain([ + [0, 0], + [1, 1], + [2, 2], + ]), + [ + [900, 900], + [901, 901], + ], + ]; + + const txt = generateCoordsTxt(segments); + + assert.equal(txt, '0,0\n1,1\n2,2\n\n900,900\n901,901\n'); + // Nothing but coordinates: no comments, no header, no stray columns. + for (const row of txt.trim().split('\n')) { + if (row === '') continue; + assert.match(row, /^\d+,\d+$/); + } +}); + +test('empty input produces an empty file', () => { + assert.equal(generateCoordsTxt([]), ''); +}); + +test('the filename carries the pixel space the coordinates are in', () => { + assert.equal(coordsFileName(SIZE), 'PINTR-1080x1350.txt'); + assert.equal(coordsFileName([800, 800]), 'PINTR-800x800.txt'); +}); + +test('every emitted row parses back to the original coordinates', () => { + const points: Coord[] = []; + for (let i = 0; i < 50; i++) { + points.push([(i * 37) % SIZE[0], (i * 53) % SIZE[1]]); + } + + const parsed = generateCoordsTxt(chain(points)) + .trim() + .split('\n') + .map((row) => row.split(',').map(Number) as Coord); + + assert.deepEqual(parsed, points); +}); diff --git a/tests/pintr-core.test.ts b/tests/pintr-core.test.ts new file mode 100644 index 0000000..a11aef4 --- /dev/null +++ b/tests/pintr-core.test.ts @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createPintr, restorePintr } from '../lib/pintr-core'; +import { + applyVignette, + extendRgbaImage, + frameRgbaImage, + preparePintrImage, + resizeRgbaImage, +} from '../lib/pintr-core/utils'; +import { processSourceImage } from '../lib/preprocess'; +import type { PintrConfig } from '../lib/pintr-core'; +import type { SourceConfig } from '../lib/preprocess'; + +const CONFIG: PintrConfig = { + contrast: 50, + definition: 50, + singleLine: true, + strokeWidth: 1.5, +}; + +function rgbaFixture(width = 32, height = 24) { + const rgba = new Uint8ClampedArray(width * height * 4); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + rgba[i] = (x * 17 + y * 3) % 256; + rgba[i + 1] = (x * 5 + y * 19) % 256; + rgba[i + 2] = (x * 11 + y * 7) % 256; + rgba[i + 3] = (x + y) % 9 === 0 ? 96 : 255; + } + } + + return { width, height, rgba }; +} + +function lines(session: ReturnType) { + return session.getLines().map((line) => [ + [line[0][0], line[0][1]], + [line[1][0], line[1][1]], + ]); +} + +test('preparation is independent of Uint8Array clamping behavior', () => { + const clamped = rgbaFixture(); + const plain = { ...clamped, rgba: new Uint8Array(clamped.rgba) }; + + assert.deepEqual( + preparePintrImage(clamped).gray, + preparePintrImage(plain).gray + ); +}); + +test('same prepared image, config, and seed is deterministic', () => { + const image = preparePintrImage(rgbaFixture()); + const a = createPintr({ image, config: CONFIG, seed: 1234 }); + const b = createPintr({ image, config: CONFIG, seed: 1234 }); + + a.next(240); + b.next(240); + assert.deepEqual(lines(a), lines(b)); +}); + +test('batch boundaries do not affect coordinates', () => { + const image = preparePintrImage(rgbaFixture()); + const whole = createPintr({ image, config: CONFIG, seed: 42 }); + const partitioned = createPintr({ image, config: CONFIG, seed: 42 }); + + whole.next(240); + partitioned.next(17); + partitioned.next(76); + partitioned.next(1); + partitioned.next(146); + + assert.deepEqual(lines(partitioned), lines(whole)); +}); + +test('checkpoints resume exactly around every feedback boundary', () => { + const image = preparePintrImage(rgbaFixture()); + // definition 50 maps to 15, so the refresh rate is 100 - floor(15 / 2). + const R = 93; + + for (const split of [R - 1, R, R + 1, 2 * R - 1, 2 * R, 2 * R + 1]) { + const target = split + 40; + const uninterrupted = createPintr({ image, config: CONFIG, seed: 99 }); + uninterrupted.next(target); + + const before = createPintr({ image, config: CONFIG, seed: 99 }); + before.next(split); + const continued = restorePintr(before.save()); + continued.next(target - split); + + assert.deepEqual(lines(continued), lines(uninterrupted), `split ${split}`); + } +}); + +test('sessions do not share cursor, source, lines, or random state', () => { + const image = preparePintrImage(rgbaFixture()); + const interleavedA = createPintr({ image, config: CONFIG, seed: 7 }); + const interleavedB = createPintr({ image, config: CONFIG, seed: 11 }); + const expectedA = createPintr({ image, config: CONFIG, seed: 7 }); + const expectedB = createPintr({ image, config: CONFIG, seed: 11 }); + + interleavedA.next(100); + interleavedB.next(40); + interleavedA.next(120); + interleavedB.next(180); + expectedA.next(220); + expectedB.next(220); + + assert.deepEqual(lines(interleavedA), lines(expectedA)); + assert.deepEqual(lines(interleavedB), lines(expectedB)); +}); + +test('a session owns its working image state', () => { + const original = preparePintrImage(rgbaFixture()); + const mutableGray = new Uint8Array(original.gray); + const image = { ...original, gray: mutableGray }; + const pintr = createPintr({ image, config: CONFIG, seed: 101 }); + const expected = createPintr({ image: original, config: CONFIG, seed: 101 }); + + mutableGray.fill(0); + pintr.next(100); + expected.next(100); + assert.deepEqual(lines(pintr), lines(expected)); +}); + +test('checkpoint corruption is rejected', () => { + const image = preparePintrImage(rgbaFixture()); + const pintr = createPintr({ image, config: CONFIG, seed: 5 }); + pintr.next(20); + const checkpoint = pintr.save(); + checkpoint[checkpoint.length - 1] ^= 0xff; + + assert.throws(() => restorePintr(checkpoint), /PINTR: invalid checkpoint/); +}); + +test('optional image utilities do not mutate their input', () => { + const image = rgbaFixture(20, 10); + const before = new Uint8ClampedArray(image.rgba); + const resized = resizeRgbaImage(image, { + longestSide: 40, + allowUpscale: true, + }); + const vignette = applyVignette(image, { + vignetteDistance: 50, + vignetteHardness: 50, + vignetteSquare: false, + }); + + assert.deepEqual(image.rgba, before); + assert.equal(resized.width, 40); + assert.equal(resized.height, 20); + assert.notStrictEqual(vignette.rgba, image.rgba); +}); + +function isWhite(image: { width: number; rgba: ArrayLike }, x: number, y: number) { + const i = (y * image.width + x) * 4; + return ( + image.rgba[i] === 255 && + image.rgba[i + 1] === 255 && + image.rgba[i + 2] === 255 && + image.rgba[i + 3] === 255 + ); +} + +test('framing gives the canvas exactly the requested aspect ratio', () => { + const landscape = rgbaFixture(80, 40); + const portrait = rgbaFixture(40, 80); + + const wide = frameRgbaImage(landscape, { aspectRatio: 16 / 9, padding: 0 }); + assert.deepEqual([wide.width, wide.height], [80, 45]); + + const tall = frameRgbaImage(landscape, { aspectRatio: 9 / 16, padding: 0 }); + assert.deepEqual([tall.width, tall.height], [45, 80]); + + const square = frameRgbaImage(portrait, { aspectRatio: 1, padding: 0 }); + assert.deepEqual([square.width, square.height], [80, 80]); +}); + +test('framing with default options keeps the source dimensions', () => { + const image = rgbaFixture(80, 40); + const framed = frameRgbaImage(image, { aspectRatio: null, padding: 0 }); + + assert.deepEqual([framed.width, framed.height], [80, 40]); +}); + +test('padding insets the picture without changing the canvas', () => { + const image = rgbaFixture(40, 40); + const framed = frameRgbaImage(image, { aspectRatio: 1, padding: 10 }); + const pad = 4; // 10% of the 40px short side + + assert.deepEqual([framed.width, framed.height], [40, 40]); + // The whole border is opaque white, and the centre is not. + for (let x = 0; x < framed.width; x++) { + assert.ok(isWhite(framed, x, pad - 1), `top border at ${x}`); + assert.ok(isWhite(framed, x, framed.height - pad), `bottom border at ${x}`); + } + for (let y = 0; y < framed.height; y++) { + assert.ok(isWhite(framed, pad - 1, y), `left border at ${y}`); + assert.ok(isWhite(framed, framed.width - pad, y), `right border at ${y}`); + } + assert.ok(!isWhite(framed, 20, 20)); +}); + +test('framing does not mutate its input', () => { + const image = rgbaFixture(20, 10); + const before = new Uint8ClampedArray(image.rgba); + + frameRgbaImage(image, { aspectRatio: 1, padding: 12 }); + + assert.deepEqual(image.rgba, before); +}); + +test('framing a vignetted image leaves the vignette keyed to the picture', () => { + // A landscape source in a square frame: the picture keeps its own size and is + // letterboxed, so the vignette must stay exactly what it was on the source — + // its centre, its radius, and (for the square mode) the short side it is + // scaled against all belong to the picture, never to the canvas. + const source = rgbaFixture(60, 40); + const vignetted = applyVignette(source, { + vignetteDistance: 50, + vignetteHardness: 50, + vignetteSquare: true, + }); + const framed = frameRgbaImage(vignetted, { aspectRatio: 1, padding: 0 }); + + assert.deepEqual([framed.width, framed.height], [60, 60]); + + const offsetY = 10; + for (let y = 0; y < framed.height; y++) { + for (let x = 0; x < framed.width; x++) { + const inPicture = y >= offsetY && y < offsetY + vignetted.height; + + if (!inPicture) { + assert.ok(isWhite(framed, x, y), `letterbox at ${x},${y}`); + continue; + } + + const target = (y * framed.width + x) * 4; + const picture = ((y - offsetY) * vignetted.width + x) * 4; + assert.deepEqual( + [ + framed.rgba[target], + framed.rgba[target + 1], + framed.rgba[target + 2], + ], + [ + vignetted.rgba[picture], + vignetted.rgba[picture + 1], + vignetted.rgba[picture + 2], + ], + `picture at ${x},${y}` + ); + } + } +}); + +// rgbaFixture is deliberately high frequency: it wraps at 256 every few pixels, +// so no blur of it stays near the pixel it started from. The extension is a blur, +// which needs a source that is actually smooth to make claims about. +function gradientFixture(width: number, height: number) { + const rgba = new Uint8ClampedArray(width * height * 4); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + rgba[i] = Math.round((x / (width - 1)) * 200) + 20; + rgba[i + 1] = Math.round((y / (height - 1)) * 200) + 20; + rgba[i + 2] = 128; + rgba[i + 3] = 255; + } + } + + return { width, height, rgba }; +} + +test('extend fills the frame with a blurred continuation of the picture', () => { + const source = gradientFixture(60, 40); + const framed = frameRgbaImage(source, { + aspectRatio: 1, + padding: 0, + extend: true, + }); + const offsetY = 10; + + assert.deepEqual([framed.width, framed.height], [60, 60]); + + // Nothing is left white for PINTR to read as empty. + for (let y = 0; y < framed.height; y++) { + for (let x = 0; x < framed.width; x++) { + assert.ok(!isWhite(framed, x, y), `fill at ${x},${y}`); + } + } + + // The picture is copied through byte for byte: the extension only ever writes + // outside it. + for (let y = 0; y < source.height; y++) { + for (let x = 0; x < source.width; x++) { + const target = ((y + offsetY) * framed.width + x) * 4; + const original = (y * source.width + x) * 4; + assert.deepEqual( + Array.from(framed.rgba.slice(target, target + 4)), + Array.from(source.rgba.slice(original, original + 4)), + `picture at ${x},${y}` + ); + } + } + + // The row against the picture is one pixel from it, so it is a one-pixel + // average: continuous with the edge, but pulled toward the picture's interior + // rather than a copy of the edge the way plain replication would be. + for (let x = 0; x < framed.width; x++) { + const seam = ((offsetY - 1) * framed.width + x) * 4; + const edge = (0 * source.width + x) * 4; + + assert.ok( + Math.abs(framed.rgba[seam] - source.rgba[edge]) <= 6, + `seam continuity at ${x}` + ); + assert.ok( + framed.rgba[seam + 1] > source.rgba[edge + 1], + `seam averages inward at ${x}` + ); + } +}); + +test('extending weights the picture pixels nearest the point it fills', () => { + // A tall gradient extended straight up. Every box is centred on the same top + // edge pixel, so the only thing separating a weighted average from a flat one + // is how much say the rows far down the picture get. + const picture = gradientFixture(40, 200); + const distance = 100; + const extended = extendRgbaImage(picture, { + canvasWidth: picture.width, + canvasHeight: picture.height + distance, + offsetX: 0, + offsetY: distance, + }); + + const x = 20; + const green = ( + image: { width: number; rgba: ArrayLike }, + px: number, + py: number + ) => image.rgba[(py * image.width + px) * 4 + 1]; + + // The flat average over the same window: every pixel from the edge out to the + // full radius, counted once. + let total = 0; + let count = 0; + for (let y = 0; y <= distance; y++) { + for (let cx = Math.max(0, x - distance); cx <= Math.min(picture.width - 1, x + distance); cx++) { + total += green(picture, cx, y); + count += 1; + } + } + const flat = total / count; + + const filled = green(extended, x, 0); + const edge = green(picture, x, 0); + + // It is pulled off the edge value — it is still an average — but it lands well + // short of the flat one, because the near rows outvote the far ones. + assert.ok(filled > edge, `${filled} should be pulled in from the edge ${edge}`); + assert.ok(filled < flat - 20, `${filled} should stay well under the flat ${flat}`); +}); + +test('extending converges on the picture average far away from it', () => { + // Out here even the narrowest of the nested boxes is wider than the picture, + // so every one of them covers all of it and the weighting has nothing left to + // favour: the fill settles on the picture's overall tone. + const picture = gradientFixture(8, 8); + const extended = extendRgbaImage(picture, { + canvasWidth: 200, + canvasHeight: 200, + offsetX: 96, + offsetY: 96, + }); + + const mean = [0, 0, 0, 0]; + for (let i = 0; i < picture.rgba.length; i += 4) { + for (let channel = 0; channel < 4; channel++) { + mean[channel] += picture.rgba[i + channel]; + } + } + const count = picture.width * picture.height; + + for (const [x, y] of [ + [0, 0], + [199, 0], + [0, 199], + [199, 199], + ]) { + const corner = (y * extended.width + x) * 4; + for (let channel = 0; channel < 4; channel++) { + assert.ok( + Math.abs(extended.rgba[corner + channel] - mean[channel] / count) <= 1, + `corner ${x},${y} channel ${channel}` + ); + } + } +}); + +test('extending is deterministic and does not mutate its input', () => { + const picture = gradientFixture(30, 20); + const before = new Uint8ClampedArray(picture.rgba); + const options = { + canvasWidth: 60, + canvasHeight: 60, + offsetX: 15, + offsetY: 20, + }; + + const first = extendRgbaImage(picture, options); + const second = extendRgbaImage(picture, options); + + assert.deepEqual(picture.rgba, before); + assert.deepEqual(first.rgba, second.rgba); +}); + +const SOURCE: SourceConfig = { + aspectRatio: '1:1', + padding: 0, + extend: false, + vignette: true, + vignetteDistance: 50, + vignetteHardness: 50, + vignetteSquare: true, +}; + +const VIGNETTE = { + vignetteDistance: SOURCE.vignetteDistance, + vignetteHardness: SOURCE.vignetteHardness, + vignetteSquare: SOURCE.vignetteSquare, +}; + +test('without extend the vignette is applied under the frame', () => { + const source = rgbaFixture(60, 40); + const processed = processSourceImage(source, SOURCE); + const expected = frameRgbaImage(applyVignette(source, VIGNETTE), { + aspectRatio: 1, + padding: 0, + }); + + assert.deepEqual(processed.rgba, expected.rgba); + // The frame is pure white margin the vignette never reached. + assert.ok(isWhite(processed, 30, 0)); +}); + +test('with extend the vignette is applied on top of the extended frame', () => { + const source = rgbaFixture(60, 40); + const processed = processSourceImage(source, { ...SOURCE, extend: true }); + const expected = applyVignette( + frameRgbaImage(source, { aspectRatio: 1, padding: 0, extend: true }), + VIGNETTE + ); + + assert.deepEqual(processed.rgba, expected.rgba); + + // The vignette now reaches into the corners the extend filled in: they are + // faded well toward white, while the centre is left alone. + const plain = frameRgbaImage(source, { + aspectRatio: 1, + padding: 0, + extend: true, + }); + const red = (image: typeof plain, x: number, y: number) => + image.rgba[(y * image.width + x) * 4]; + + assert.ok(red(processed, 0, 0) > red(plain, 0, 0) + 100, 'corner faded'); + assert.equal(red(processed, 30, 30), red(plain, 30, 30), 'centre untouched'); +}); diff --git a/tests/register.mjs b/tests/register.mjs new file mode 100644 index 0000000..8f79fe0 --- /dev/null +++ b/tests/register.mjs @@ -0,0 +1,26 @@ +// The source uses extensionless relative imports (what Next/TypeScript resolve +// against). Node's ESM resolver needs the real filename, so retry with the +// extensions the repo actually uses instead of rewriting every import. +import { registerHooks } from 'node:module'; + +const SUFFIXES = ['.ts', '/index.ts']; + +registerHooks({ + resolve(specifier, context, nextResolve) { + try { + return nextResolve(specifier, context); + } catch (error) { + if (!specifier.startsWith('.')) throw error; + + for (const suffix of SUFFIXES) { + try { + return nextResolve(specifier + suffix, context); + } catch { + // try the next candidate + } + } + + throw error; + } + }, +}); From 7d662760fea19a1843883e851086fae91587509c Mon Sep 17 00:00:00 2001 From: javierbyte Date: Sat, 5 Sep 2026 01:20:06 +0200 Subject: [PATCH 2/5] simplify --- README.md | 2 +- lib/PINTR.ts | 17 +- lib/pintr-core/README.md | 30 +-- lib/pintr-core/checkpoint.ts | 189 -------------- lib/pintr-core/createPintr.ts | 126 ++------- lib/pintr-core/index.ts | 3 +- lib/pintr-core/random.ts | 18 +- lib/pintr-core/source.ts | 3 - lib/pintr-core/types.ts | 4 - package.json | 3 +- tests/coords-txt.test.ts | 137 ---------- tests/pintr-core.test.ts | 475 ---------------------------------- tests/register.mjs | 26 -- 13 files changed, 63 insertions(+), 970 deletions(-) delete mode 100644 lib/pintr-core/checkpoint.ts delete mode 100644 tests/coords-txt.test.ts delete mode 100644 tests/pintr-core.test.ts delete mode 100644 tests/register.mjs diff --git a/README.md b/README.md index c267d89..2500537 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ illustrations from your pictures. All the processing is made locally, your images never leave your computer and you are free to use them as you please. -The platform-neutral, resumable generator is documented in +The platform-neutral, incremental generator is documented in [PINTR core](lib/pintr-core/README.md). It is currently a local module; this refactor does not create or publish a package. diff --git a/lib/PINTR.ts b/lib/PINTR.ts index ebb7941..a18555c 100644 --- a/lib/PINTR.ts +++ b/lib/PINTR.ts @@ -77,8 +77,10 @@ export async function pinterCreator( let running = false; let stopped = false; - // Draws toward the current target for one browser frame budget. Requesting - // one core line at a time keeps the target exact even when Definition is slow. + // Draws lines toward `session.target` within a single ~15ms time budget, then + // resolves on the next animation frame so the canvas can paint between batches. + // Requesting one core line at a time keeps the target exact even when Definition + // is slow. function drawBatch(currentSession: Session) { return new Promise((resolve) => { const time = Date.now(); @@ -106,8 +108,10 @@ export async function pinterCreator( }); } - // A live session can be replaced while the previous batch is waiting for its - // animation frame. Reading `session` again each loop picks up the replacement. + // Keeps batching until the drawn count reaches the current target, then idles; + // raising the target via `requestLines` restarts it. The core session stays + // warm while idle, so it continues without replaying the existing drawing. + // Reading `session` each iteration also picks up a replacement made by start(). async function pump() { if (running) return; running = true; @@ -121,6 +125,8 @@ export async function pinterCreator( running = false; } + // Start a fresh generator for this image and config. The core owns only the + // state needed to choose future lines; the app collects the returned coords. function start(config: PintrConfig) { drawContext.clearRect(0, 0, WIDTH, HEIGHT); pencilDraw = Draw(drawContext, config.singleLine); @@ -131,6 +137,8 @@ export async function pinterCreator( }; } + // Set the target line count and return immediately. Lower targets simply stop + // generation; raising the target later continues the same in-memory session. function requestLines(targetCount: number) { if (!session) throw new Error('PINTR: call start() before requestLines()'); if (!Number.isInteger(targetCount) || targetCount < 0) { @@ -141,6 +149,7 @@ export async function pinterCreator( if (!running) pump(); } + // Permanently halt this instance when a new image takes over the canvas. function stop() { stopped = true; } diff --git a/lib/pintr-core/README.md b/lib/pintr-core/README.md index 1458778..1cc14c8 100644 --- a/lib/pintr-core/README.md +++ b/lib/pintr-core/README.md @@ -1,7 +1,7 @@ # PINTR core ```ts -import { createPintr, restorePintr } from './lib/pintr-core'; +import { createPintr } from './lib/pintr-core'; import { preparePintrImage } from './lib/pintr-core/utils'; const image = preparePintrImage({ width, height, rgba }); @@ -22,11 +22,6 @@ draw(first.lines); // Nothing runs between calls. Calling next() continues at line 128. const second = pintr.next(128); draw(second.lines); - -// A checkpoint contains the image's current working state and all prior lines. -const checkpoint = pintr.save(); -const restored = restorePintr(checkpoint); -draw(restored.next(128).lines); ``` The implementation is a local module for now; no package is created by this @@ -40,16 +35,18 @@ DOM, Canvas, decoder, renderer, timer, or platform dependency. ```ts createPintr({ image, config, seed? }): PintrSession; -restorePintr(checkpoint): PintrSession; session.next(count): PintrBatch; -session.getLines(from?, to?): PintrLine[]; -session.save(): Uint8Array; ``` `next(count)` synchronously generates exactly that many lines. The session is naturally paused as soon as the call returns; call it again whenever the host is -ready. Batch boundaries do not affect the generated coordinates. +ready. Its working image, cursor, and random generator stay warm between calls, +and batch boundaries do not affect the generated coordinates. + +The core only returns new lines. A host that needs the complete drawing owns +that list and appends each batch to it. This keeps generation state small and +lets a browser draw lines immediately without storing them twice. The input image is: @@ -83,17 +80,18 @@ function frame() { A CLI can use larger batches or generate its whole target in one call: ```ts +const lines = []; + while (pintr.lineCount < targetLineCount) { - pintr.next(Math.min(1000, targetLineCount - pintr.lineCount)); + const batch = pintr.next( + Math.min(1000, targetLineCount - pintr.lineCount) + ); + lines.push(...batch.lines); } -writeCoordinates(pintr.getLines()); +writeCoordinates(lines); ``` -Saving is also synchronous. The returned, versioned binary checkpoint is -self-contained, so React Native or a CLI can persist it and later restore the -session without decoding the original image again. - ## Optional image utilities `./lib/pintr-core/utils` contains pure, non-mutating helpers that are not needed diff --git a/lib/pintr-core/checkpoint.ts b/lib/pintr-core/checkpoint.ts deleted file mode 100644 index 102fcf3..0000000 --- a/lib/pintr-core/checkpoint.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { PintrConfig, PintrLine, PintrPoint } from './types'; -import { PintrError } from './types'; - -const MAGIC = [80, 73, 78, 84, 82, 67, 80, 49] as const; // PINTRCP1 -const HEADER_SIZE = 20; -const PAYLOAD_HEADER_SIZE = 64; -const FORMAT_VERSION = 1; -const ALGORITHM_VERSION = 1; -const BYTES_PER_LINE = 16; -const MAX_PIXELS = 100_000_000; - -export type CheckpointState = { - width: number; - height: number; - config: PintrConfig; - seed: number; - randomState: number; - cursor: PintrPoint; - gray: Uint8Array; - lines: PintrLine[]; - pendingCount: number; -}; - -function checksum(bytes: Uint8Array, start: number) { - let hash = 0x811c9dc5; - for (let i = start; i < bytes.length; i++) { - hash ^= bytes[i]; - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - -function fail(message: string): never { - throw new PintrError(`invalid checkpoint: ${message}`); -} - -export function encodeCheckpoint(state: CheckpointState) { - const totalLength = - HEADER_SIZE + - PAYLOAD_HEADER_SIZE + - state.gray.length + - state.lines.length * BYTES_PER_LINE; - const bytes = new Uint8Array(totalLength); - const view = new DataView(bytes.buffer); - - for (let i = 0; i < MAGIC.length; i++) bytes[i] = MAGIC[i]; - view.setUint16(8, FORMAT_VERSION, true); - view.setUint16(10, ALGORITHM_VERSION, true); - view.setUint32(12, totalLength, true); - - let offset = HEADER_SIZE; - view.setUint32(offset, state.width, true); - offset += 4; - view.setUint32(offset, state.height, true); - offset += 4; - view.setFloat64(offset, state.config.contrast, true); - offset += 8; - view.setFloat64(offset, state.config.definition, true); - offset += 8; - view.setFloat64(offset, state.config.strokeWidth, true); - offset += 8; - view.setUint8(offset, state.config.singleLine ? 1 : 0); - offset += 4; // one boolean plus three reserved bytes - view.setUint32(offset, state.seed, true); - offset += 4; - view.setUint32(offset, state.randomState, true); - offset += 4; - view.setUint32(offset, state.cursor[0], true); - offset += 4; - view.setUint32(offset, state.cursor[1], true); - offset += 4; - view.setUint32(offset, state.lines.length, true); - offset += 4; - view.setUint32(offset, state.pendingCount, true); - offset += 4; - view.setUint32(offset, state.gray.length, true); - offset += 4; - - bytes.set(state.gray, offset); - offset += state.gray.length; - - for (const line of state.lines) { - view.setUint32(offset, line[0][0], true); - view.setUint32(offset + 4, line[0][1], true); - view.setUint32(offset + 8, line[1][0], true); - view.setUint32(offset + 12, line[1][1], true); - offset += BYTES_PER_LINE; - } - - view.setUint32(16, checksum(bytes, HEADER_SIZE), true); - return bytes; -} - -export function decodeCheckpoint(input: Uint8Array): CheckpointState { - const bytes = new Uint8Array(input); - if (bytes.length < HEADER_SIZE + PAYLOAD_HEADER_SIZE) fail('truncated header'); - - for (let i = 0; i < MAGIC.length; i++) { - if (bytes[i] !== MAGIC[i]) fail('wrong magic header'); - } - - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (view.getUint16(8, true) !== FORMAT_VERSION) { - fail('unsupported format version'); - } - if (view.getUint16(10, true) !== ALGORITHM_VERSION) { - fail('unsupported algorithm version'); - } - if (view.getUint32(12, true) !== bytes.length) fail('wrong byte length'); - if (view.getUint32(16, true) !== checksum(bytes, HEADER_SIZE)) { - fail('checksum mismatch'); - } - - let offset = HEADER_SIZE; - const width = view.getUint32(offset, true); - offset += 4; - const height = view.getUint32(offset, true); - offset += 4; - const contrast = view.getFloat64(offset, true); - offset += 8; - const definition = view.getFloat64(offset, true); - offset += 8; - const strokeWidth = view.getFloat64(offset, true); - offset += 8; - const singleLineByte = view.getUint8(offset); - offset += 4; - const seed = view.getUint32(offset, true); - offset += 4; - const randomState = view.getUint32(offset, true); - offset += 4; - const cursorX = view.getUint32(offset, true); - offset += 4; - const cursorY = view.getUint32(offset, true); - offset += 4; - const lineCount = view.getUint32(offset, true); - offset += 4; - const pendingCount = view.getUint32(offset, true); - offset += 4; - const grayLength = view.getUint32(offset, true); - offset += 4; - - if (!width || !height || width * height > MAX_PIXELS) { - fail('invalid image dimensions'); - } - if (grayLength !== width * height) fail('wrong grayscale length'); - if (singleLineByte > 1) fail('invalid singleLine value'); - if (pendingCount > lineCount) fail('pending lines exceed line count'); - if (cursorX >= width || cursorY >= height) fail('cursor is outside the image'); - - const expectedLength = - HEADER_SIZE + - PAYLOAD_HEADER_SIZE + - grayLength + - lineCount * BYTES_PER_LINE; - if (expectedLength !== bytes.length) fail('inconsistent payload lengths'); - - const gray = bytes.slice(offset, offset + grayLength); - offset += grayLength; - const lines: PintrLine[] = []; - - for (let i = 0; i < lineCount; i++) { - const fromX = view.getUint32(offset, true); - const fromY = view.getUint32(offset + 4, true); - const toX = view.getUint32(offset + 8, true); - const toY = view.getUint32(offset + 12, true); - offset += BYTES_PER_LINE; - - if (fromX >= width || toX >= width || fromY >= height || toY >= height) { - fail('line coordinate is outside the image'); - } - - lines.push([ - [fromX, fromY], - [toX, toY], - ]); - } - - return { - width, - height, - config: { contrast, definition, singleLine: Boolean(singleLineByte), strokeWidth }, - seed, - randomState, - cursor: [cursorX, cursorY], - gray, - lines, - pendingCount, - }; -} diff --git a/lib/pintr-core/createPintr.ts b/lib/pintr-core/createPintr.ts index 31f39fd..2dff4d2 100644 --- a/lib/pintr-core/createPintr.ts +++ b/lib/pintr-core/createPintr.ts @@ -1,11 +1,9 @@ -import { decodeCheckpoint, encodeCheckpoint } from './checkpoint'; import { tweenValue } from './math'; import { createRandom } from './random'; import { scanLine } from './scan'; import { createSource } from './source'; import type { PintrBatch, - PintrCheckpoint, PintrConfig, PintrImage, PintrLine, @@ -16,14 +14,6 @@ import { PintrError } from './types'; const MAX_PIXELS = 100_000_000; -type RestoredState = { - randomState: number; - cursor: PintrPoint; - gray: Uint8Array; - lines: PintrLine[]; - pendingCount: number; -}; - function validateImage(image: PintrImage) { if (!Number.isInteger(image.width) || !Number.isInteger(image.height)) { throw new PintrError('image dimensions must be integers'); @@ -61,19 +51,26 @@ function validateConfig(config: PintrConfig) { } } -function createSession( - image: PintrImage, - inputConfig: PintrConfig, - seed: number, - restored?: RestoredState -): PintrSession { +export function createPintr(input: { + image: PintrImage; + config: PintrConfig; + seed?: number; +}): PintrSession { + const { image } = input; + const seed = + input.seed === undefined + ? Math.floor(Math.random() * 0x100000000) + : input.seed; + validateImage(image); - validateConfig(inputConfig); + validateConfig(input.config); if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) { throw new PintrError('seed must be a uint32'); } - const config = { ...inputConfig }; + // Everything needed to continue a drawing stays in this closure. Calling + // next() later resumes from these values without replaying earlier lines. + const config = { ...input.config }; const tweenDefinition = Math.round( tweenValue(config.definition, [ [0, 3], @@ -92,19 +89,14 @@ function createSession( )) / 100; const updateSampleRate = 100 - Math.floor(tweenDefinition / 2); - const source = createSource( - image.width, - image.height, - restored ? restored.gray : image.gray - ); - const random = createRandom(seed, restored?.randomState); - const lines: PintrLine[] = restored ? restored.lines : []; - let pendingLines: PintrLine[] = restored - ? lines.slice(lines.length - restored.pendingCount) - : []; - let cursor: PintrPoint = restored - ? restored.cursor - : [Math.floor(image.width / 2), Math.floor(image.height / 2)]; + const source = createSource(image.width, image.height, image.gray); + const random = createRandom(seed); + let pendingLines: PintrLine[] = []; + let cursor: PintrPoint = [ + Math.floor(image.width / 2), + Math.floor(image.height / 2), + ]; + let generatedLineCount = 0; function drawSequenceLine() { let from = cursor; @@ -145,7 +137,6 @@ function createSession( light = scanLine(from, to, source.gray, image.height); const line: PintrLine = [from, to]; - lines.push(line); pendingLines.push(line); cursor = line[1]; @@ -157,95 +148,34 @@ function createSession( throw new PintrError('next() lineCount must be a positive integer'); } - const startLine = lines.length; + const startLine = generatedLineCount; const batch: PintrLine[] = []; for (let i = 0; i < lineCount; i++) { // Keep this before the next line: exactly R generated lines remain pending // until line R asks the source to refresh. - if (lines.length % updateSampleRate === 0) { + if (generatedLineCount % updateSampleRate === 0) { source.erase(pendingLines, config.strokeWidth * 1.5, feedbackAlpha); pendingLines = []; } batch.push(drawSequenceLine()); + generatedLineCount++; } return { startLine, - endLine: lines.length, + endLine: generatedLineCount, lines: batch, }; } - function getLines(from = 0, to = lines.length) { - if ( - !Number.isInteger(from) || - !Number.isInteger(to) || - from < 0 || - to < from || - to > lines.length - ) { - throw new PintrError('getLines() range is outside generated lines'); - } - - return lines.slice(from, to); - } - - function save(): PintrCheckpoint { - return encodeCheckpoint({ - width: image.width, - height: image.height, - config, - seed, - randomState: random.state, - cursor, - gray: source.snapshot(), - lines, - pendingCount: pendingLines.length, - }); - } - return { width: image.width, height: image.height, seed, get lineCount() { - return lines.length; + return generatedLineCount; }, next, - getLines, - save, }; } - -export function createPintr(input: { - image: PintrImage; - config: PintrConfig; - seed?: number; -}) { - const seed = - input.seed === undefined - ? Math.floor(Math.random() * 0x100000000) - : input.seed; - return createSession(input.image, input.config, seed); -} - -export function restorePintr(checkpoint: PintrCheckpoint) { - if (!(checkpoint instanceof Uint8Array)) { - throw new PintrError('checkpoint must be a Uint8Array'); - } - - const state = decodeCheckpoint(checkpoint); - return createSession( - { width: state.width, height: state.height, gray: state.gray }, - state.config, - state.seed, - { - randomState: state.randomState, - cursor: state.cursor, - gray: state.gray, - lines: state.lines, - pendingCount: state.pendingCount, - } - ); -} diff --git a/lib/pintr-core/index.ts b/lib/pintr-core/index.ts index 6c07359..f6a50db 100644 --- a/lib/pintr-core/index.ts +++ b/lib/pintr-core/index.ts @@ -1,8 +1,7 @@ -export { createPintr, restorePintr } from './createPintr'; +export { createPintr } from './createPintr'; export { PintrError } from './types'; export type { PintrBatch, - PintrCheckpoint, PintrConfig, PintrImage, PintrLine, diff --git a/lib/pintr-core/random.ts b/lib/pintr-core/random.ts index e764ddc..b7c9e72 100644 --- a/lib/pintr-core/random.ts +++ b/lib/pintr-core/random.ts @@ -1,11 +1,9 @@ const STEP = 0x6d2b79f5; -export type PintrRandom = ReturnType; - -// PINTR only needs one uint32 of random state. The call sites stay in the same -// order as Math.random did, while checkpoints can now continue the sequence. -export function createRandom(seed: number, restoredState?: number) { - let state = (restoredState === undefined ? seed : restoredState) >>> 0; +// A seed makes a drawing reproducible without changing how the generator asks +// for random integers. +export function createRandom(seed: number) { + let state = seed >>> 0; function next() { state = (state + STEP) >>> 0; @@ -24,11 +22,5 @@ export function createRandom(seed: number, restoredState?: number) { ); } - return { - next, - int, - get state() { - return state; - }, - }; + return { int }; } diff --git a/lib/pintr-core/source.ts b/lib/pintr-core/source.ts index 281b652..d3676dc 100644 --- a/lib/pintr-core/source.ts +++ b/lib/pintr-core/source.ts @@ -113,8 +113,5 @@ export function createSource( return { gray, erase, - snapshot() { - return new Uint8Array(gray); - }, }; } diff --git a/lib/pintr-core/types.ts b/lib/pintr-core/types.ts index 79c8781..9269d7c 100644 --- a/lib/pintr-core/types.ts +++ b/lib/pintr-core/types.ts @@ -22,16 +22,12 @@ export type PintrBatch = { lines: PintrLine[]; }; -export type PintrCheckpoint = Uint8Array; - export type PintrSession = { width: number; height: number; seed: number; lineCount: number; next(lineCount: number): PintrBatch; - getLines(from?: number, to?: number): PintrLine[]; - save(): PintrCheckpoint; }; export class PintrError extends Error { diff --git a/package.json b/package.json index 54ed886..c37562a 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "type-check": "tsc --noEmit -p tsconfig.json", - "test": "node --import ./tests/register.mjs --test tests/*.test.ts" + "type-check": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { "@types/node": "26.4.0", diff --git a/tests/coords-txt.test.ts b/tests/coords-txt.test.ts deleted file mode 100644 index 295d688..0000000 --- a/tests/coords-txt.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - coordsFileName, - generateCoordsTxt, - segmentsToPaths, -} from '../lib/coords-txt'; -import type { Coord } from '../lib/PINTR'; - -const SIZE: [number, number] = [1080, 1350]; - -// A chain, as single-line mode emits it: each segment starts where the last ended. -function chain(points: Coord[]): [Coord, Coord][] { - const segments: [Coord, Coord][] = []; - for (let i = 1; i < points.length; i++) { - segments.push([points[i - 1], points[i]]); - } - return segments; -} - -test('a chained segment list collapses to one path keeping every point', () => { - const points: Coord[] = [ - [0, 0], - [10, 20], - [30, 40], - [50, 60], - ]; - - const paths = segmentsToPaths(chain(points)); - - assert.equal(paths.length, 1); - assert.equal(paths[0].length, points.length); - assert.deepEqual(paths[0], points); -}); - -test('a disjoint segment list yields one two-point path per segment', () => { - const segments: [Coord, Coord][] = [ - [ - [0, 0], - [1, 1], - ], - [ - [10, 10], - [11, 11], - ], - [ - [20, 20], - [21, 21], - ], - ]; - - const paths = segmentsToPaths(segments); - - assert.equal(paths.length, segments.length); - for (const path of paths) assert.equal(path.length, 2); - assert.deepEqual(paths, segments); -}); - -test('a mixed list splits at exactly the discontinuities', () => { - const segments: [Coord, Coord][] = [ - ...chain([ - [0, 0], - [1, 1], - [2, 2], - ]), - // Jump: starts somewhere other than [2, 2]. - ...chain([ - [50, 50], - [51, 51], - ]), - // Continues from [51, 51], so it stays in the same path. - [ - [51, 51], - [52, 52], - ], - ]; - - assert.deepEqual(segmentsToPaths(segments), [ - [ - [0, 0], - [1, 1], - [2, 2], - ], - [ - [50, 50], - [51, 51], - [52, 52], - ], - ]); -}); - -test('paths are separated by a blank line and rows are bare coordinates', () => { - const segments: [Coord, Coord][] = [ - ...chain([ - [0, 0], - [1, 1], - [2, 2], - ]), - [ - [900, 900], - [901, 901], - ], - ]; - - const txt = generateCoordsTxt(segments); - - assert.equal(txt, '0,0\n1,1\n2,2\n\n900,900\n901,901\n'); - // Nothing but coordinates: no comments, no header, no stray columns. - for (const row of txt.trim().split('\n')) { - if (row === '') continue; - assert.match(row, /^\d+,\d+$/); - } -}); - -test('empty input produces an empty file', () => { - assert.equal(generateCoordsTxt([]), ''); -}); - -test('the filename carries the pixel space the coordinates are in', () => { - assert.equal(coordsFileName(SIZE), 'PINTR-1080x1350.txt'); - assert.equal(coordsFileName([800, 800]), 'PINTR-800x800.txt'); -}); - -test('every emitted row parses back to the original coordinates', () => { - const points: Coord[] = []; - for (let i = 0; i < 50; i++) { - points.push([(i * 37) % SIZE[0], (i * 53) % SIZE[1]]); - } - - const parsed = generateCoordsTxt(chain(points)) - .trim() - .split('\n') - .map((row) => row.split(',').map(Number) as Coord); - - assert.deepEqual(parsed, points); -}); diff --git a/tests/pintr-core.test.ts b/tests/pintr-core.test.ts deleted file mode 100644 index a11aef4..0000000 --- a/tests/pintr-core.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { createPintr, restorePintr } from '../lib/pintr-core'; -import { - applyVignette, - extendRgbaImage, - frameRgbaImage, - preparePintrImage, - resizeRgbaImage, -} from '../lib/pintr-core/utils'; -import { processSourceImage } from '../lib/preprocess'; -import type { PintrConfig } from '../lib/pintr-core'; -import type { SourceConfig } from '../lib/preprocess'; - -const CONFIG: PintrConfig = { - contrast: 50, - definition: 50, - singleLine: true, - strokeWidth: 1.5, -}; - -function rgbaFixture(width = 32, height = 24) { - const rgba = new Uint8ClampedArray(width * height * 4); - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const i = (y * width + x) * 4; - rgba[i] = (x * 17 + y * 3) % 256; - rgba[i + 1] = (x * 5 + y * 19) % 256; - rgba[i + 2] = (x * 11 + y * 7) % 256; - rgba[i + 3] = (x + y) % 9 === 0 ? 96 : 255; - } - } - - return { width, height, rgba }; -} - -function lines(session: ReturnType) { - return session.getLines().map((line) => [ - [line[0][0], line[0][1]], - [line[1][0], line[1][1]], - ]); -} - -test('preparation is independent of Uint8Array clamping behavior', () => { - const clamped = rgbaFixture(); - const plain = { ...clamped, rgba: new Uint8Array(clamped.rgba) }; - - assert.deepEqual( - preparePintrImage(clamped).gray, - preparePintrImage(plain).gray - ); -}); - -test('same prepared image, config, and seed is deterministic', () => { - const image = preparePintrImage(rgbaFixture()); - const a = createPintr({ image, config: CONFIG, seed: 1234 }); - const b = createPintr({ image, config: CONFIG, seed: 1234 }); - - a.next(240); - b.next(240); - assert.deepEqual(lines(a), lines(b)); -}); - -test('batch boundaries do not affect coordinates', () => { - const image = preparePintrImage(rgbaFixture()); - const whole = createPintr({ image, config: CONFIG, seed: 42 }); - const partitioned = createPintr({ image, config: CONFIG, seed: 42 }); - - whole.next(240); - partitioned.next(17); - partitioned.next(76); - partitioned.next(1); - partitioned.next(146); - - assert.deepEqual(lines(partitioned), lines(whole)); -}); - -test('checkpoints resume exactly around every feedback boundary', () => { - const image = preparePintrImage(rgbaFixture()); - // definition 50 maps to 15, so the refresh rate is 100 - floor(15 / 2). - const R = 93; - - for (const split of [R - 1, R, R + 1, 2 * R - 1, 2 * R, 2 * R + 1]) { - const target = split + 40; - const uninterrupted = createPintr({ image, config: CONFIG, seed: 99 }); - uninterrupted.next(target); - - const before = createPintr({ image, config: CONFIG, seed: 99 }); - before.next(split); - const continued = restorePintr(before.save()); - continued.next(target - split); - - assert.deepEqual(lines(continued), lines(uninterrupted), `split ${split}`); - } -}); - -test('sessions do not share cursor, source, lines, or random state', () => { - const image = preparePintrImage(rgbaFixture()); - const interleavedA = createPintr({ image, config: CONFIG, seed: 7 }); - const interleavedB = createPintr({ image, config: CONFIG, seed: 11 }); - const expectedA = createPintr({ image, config: CONFIG, seed: 7 }); - const expectedB = createPintr({ image, config: CONFIG, seed: 11 }); - - interleavedA.next(100); - interleavedB.next(40); - interleavedA.next(120); - interleavedB.next(180); - expectedA.next(220); - expectedB.next(220); - - assert.deepEqual(lines(interleavedA), lines(expectedA)); - assert.deepEqual(lines(interleavedB), lines(expectedB)); -}); - -test('a session owns its working image state', () => { - const original = preparePintrImage(rgbaFixture()); - const mutableGray = new Uint8Array(original.gray); - const image = { ...original, gray: mutableGray }; - const pintr = createPintr({ image, config: CONFIG, seed: 101 }); - const expected = createPintr({ image: original, config: CONFIG, seed: 101 }); - - mutableGray.fill(0); - pintr.next(100); - expected.next(100); - assert.deepEqual(lines(pintr), lines(expected)); -}); - -test('checkpoint corruption is rejected', () => { - const image = preparePintrImage(rgbaFixture()); - const pintr = createPintr({ image, config: CONFIG, seed: 5 }); - pintr.next(20); - const checkpoint = pintr.save(); - checkpoint[checkpoint.length - 1] ^= 0xff; - - assert.throws(() => restorePintr(checkpoint), /PINTR: invalid checkpoint/); -}); - -test('optional image utilities do not mutate their input', () => { - const image = rgbaFixture(20, 10); - const before = new Uint8ClampedArray(image.rgba); - const resized = resizeRgbaImage(image, { - longestSide: 40, - allowUpscale: true, - }); - const vignette = applyVignette(image, { - vignetteDistance: 50, - vignetteHardness: 50, - vignetteSquare: false, - }); - - assert.deepEqual(image.rgba, before); - assert.equal(resized.width, 40); - assert.equal(resized.height, 20); - assert.notStrictEqual(vignette.rgba, image.rgba); -}); - -function isWhite(image: { width: number; rgba: ArrayLike }, x: number, y: number) { - const i = (y * image.width + x) * 4; - return ( - image.rgba[i] === 255 && - image.rgba[i + 1] === 255 && - image.rgba[i + 2] === 255 && - image.rgba[i + 3] === 255 - ); -} - -test('framing gives the canvas exactly the requested aspect ratio', () => { - const landscape = rgbaFixture(80, 40); - const portrait = rgbaFixture(40, 80); - - const wide = frameRgbaImage(landscape, { aspectRatio: 16 / 9, padding: 0 }); - assert.deepEqual([wide.width, wide.height], [80, 45]); - - const tall = frameRgbaImage(landscape, { aspectRatio: 9 / 16, padding: 0 }); - assert.deepEqual([tall.width, tall.height], [45, 80]); - - const square = frameRgbaImage(portrait, { aspectRatio: 1, padding: 0 }); - assert.deepEqual([square.width, square.height], [80, 80]); -}); - -test('framing with default options keeps the source dimensions', () => { - const image = rgbaFixture(80, 40); - const framed = frameRgbaImage(image, { aspectRatio: null, padding: 0 }); - - assert.deepEqual([framed.width, framed.height], [80, 40]); -}); - -test('padding insets the picture without changing the canvas', () => { - const image = rgbaFixture(40, 40); - const framed = frameRgbaImage(image, { aspectRatio: 1, padding: 10 }); - const pad = 4; // 10% of the 40px short side - - assert.deepEqual([framed.width, framed.height], [40, 40]); - // The whole border is opaque white, and the centre is not. - for (let x = 0; x < framed.width; x++) { - assert.ok(isWhite(framed, x, pad - 1), `top border at ${x}`); - assert.ok(isWhite(framed, x, framed.height - pad), `bottom border at ${x}`); - } - for (let y = 0; y < framed.height; y++) { - assert.ok(isWhite(framed, pad - 1, y), `left border at ${y}`); - assert.ok(isWhite(framed, framed.width - pad, y), `right border at ${y}`); - } - assert.ok(!isWhite(framed, 20, 20)); -}); - -test('framing does not mutate its input', () => { - const image = rgbaFixture(20, 10); - const before = new Uint8ClampedArray(image.rgba); - - frameRgbaImage(image, { aspectRatio: 1, padding: 12 }); - - assert.deepEqual(image.rgba, before); -}); - -test('framing a vignetted image leaves the vignette keyed to the picture', () => { - // A landscape source in a square frame: the picture keeps its own size and is - // letterboxed, so the vignette must stay exactly what it was on the source — - // its centre, its radius, and (for the square mode) the short side it is - // scaled against all belong to the picture, never to the canvas. - const source = rgbaFixture(60, 40); - const vignetted = applyVignette(source, { - vignetteDistance: 50, - vignetteHardness: 50, - vignetteSquare: true, - }); - const framed = frameRgbaImage(vignetted, { aspectRatio: 1, padding: 0 }); - - assert.deepEqual([framed.width, framed.height], [60, 60]); - - const offsetY = 10; - for (let y = 0; y < framed.height; y++) { - for (let x = 0; x < framed.width; x++) { - const inPicture = y >= offsetY && y < offsetY + vignetted.height; - - if (!inPicture) { - assert.ok(isWhite(framed, x, y), `letterbox at ${x},${y}`); - continue; - } - - const target = (y * framed.width + x) * 4; - const picture = ((y - offsetY) * vignetted.width + x) * 4; - assert.deepEqual( - [ - framed.rgba[target], - framed.rgba[target + 1], - framed.rgba[target + 2], - ], - [ - vignetted.rgba[picture], - vignetted.rgba[picture + 1], - vignetted.rgba[picture + 2], - ], - `picture at ${x},${y}` - ); - } - } -}); - -// rgbaFixture is deliberately high frequency: it wraps at 256 every few pixels, -// so no blur of it stays near the pixel it started from. The extension is a blur, -// which needs a source that is actually smooth to make claims about. -function gradientFixture(width: number, height: number) { - const rgba = new Uint8ClampedArray(width * height * 4); - - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - const i = (y * width + x) * 4; - rgba[i] = Math.round((x / (width - 1)) * 200) + 20; - rgba[i + 1] = Math.round((y / (height - 1)) * 200) + 20; - rgba[i + 2] = 128; - rgba[i + 3] = 255; - } - } - - return { width, height, rgba }; -} - -test('extend fills the frame with a blurred continuation of the picture', () => { - const source = gradientFixture(60, 40); - const framed = frameRgbaImage(source, { - aspectRatio: 1, - padding: 0, - extend: true, - }); - const offsetY = 10; - - assert.deepEqual([framed.width, framed.height], [60, 60]); - - // Nothing is left white for PINTR to read as empty. - for (let y = 0; y < framed.height; y++) { - for (let x = 0; x < framed.width; x++) { - assert.ok(!isWhite(framed, x, y), `fill at ${x},${y}`); - } - } - - // The picture is copied through byte for byte: the extension only ever writes - // outside it. - for (let y = 0; y < source.height; y++) { - for (let x = 0; x < source.width; x++) { - const target = ((y + offsetY) * framed.width + x) * 4; - const original = (y * source.width + x) * 4; - assert.deepEqual( - Array.from(framed.rgba.slice(target, target + 4)), - Array.from(source.rgba.slice(original, original + 4)), - `picture at ${x},${y}` - ); - } - } - - // The row against the picture is one pixel from it, so it is a one-pixel - // average: continuous with the edge, but pulled toward the picture's interior - // rather than a copy of the edge the way plain replication would be. - for (let x = 0; x < framed.width; x++) { - const seam = ((offsetY - 1) * framed.width + x) * 4; - const edge = (0 * source.width + x) * 4; - - assert.ok( - Math.abs(framed.rgba[seam] - source.rgba[edge]) <= 6, - `seam continuity at ${x}` - ); - assert.ok( - framed.rgba[seam + 1] > source.rgba[edge + 1], - `seam averages inward at ${x}` - ); - } -}); - -test('extending weights the picture pixels nearest the point it fills', () => { - // A tall gradient extended straight up. Every box is centred on the same top - // edge pixel, so the only thing separating a weighted average from a flat one - // is how much say the rows far down the picture get. - const picture = gradientFixture(40, 200); - const distance = 100; - const extended = extendRgbaImage(picture, { - canvasWidth: picture.width, - canvasHeight: picture.height + distance, - offsetX: 0, - offsetY: distance, - }); - - const x = 20; - const green = ( - image: { width: number; rgba: ArrayLike }, - px: number, - py: number - ) => image.rgba[(py * image.width + px) * 4 + 1]; - - // The flat average over the same window: every pixel from the edge out to the - // full radius, counted once. - let total = 0; - let count = 0; - for (let y = 0; y <= distance; y++) { - for (let cx = Math.max(0, x - distance); cx <= Math.min(picture.width - 1, x + distance); cx++) { - total += green(picture, cx, y); - count += 1; - } - } - const flat = total / count; - - const filled = green(extended, x, 0); - const edge = green(picture, x, 0); - - // It is pulled off the edge value — it is still an average — but it lands well - // short of the flat one, because the near rows outvote the far ones. - assert.ok(filled > edge, `${filled} should be pulled in from the edge ${edge}`); - assert.ok(filled < flat - 20, `${filled} should stay well under the flat ${flat}`); -}); - -test('extending converges on the picture average far away from it', () => { - // Out here even the narrowest of the nested boxes is wider than the picture, - // so every one of them covers all of it and the weighting has nothing left to - // favour: the fill settles on the picture's overall tone. - const picture = gradientFixture(8, 8); - const extended = extendRgbaImage(picture, { - canvasWidth: 200, - canvasHeight: 200, - offsetX: 96, - offsetY: 96, - }); - - const mean = [0, 0, 0, 0]; - for (let i = 0; i < picture.rgba.length; i += 4) { - for (let channel = 0; channel < 4; channel++) { - mean[channel] += picture.rgba[i + channel]; - } - } - const count = picture.width * picture.height; - - for (const [x, y] of [ - [0, 0], - [199, 0], - [0, 199], - [199, 199], - ]) { - const corner = (y * extended.width + x) * 4; - for (let channel = 0; channel < 4; channel++) { - assert.ok( - Math.abs(extended.rgba[corner + channel] - mean[channel] / count) <= 1, - `corner ${x},${y} channel ${channel}` - ); - } - } -}); - -test('extending is deterministic and does not mutate its input', () => { - const picture = gradientFixture(30, 20); - const before = new Uint8ClampedArray(picture.rgba); - const options = { - canvasWidth: 60, - canvasHeight: 60, - offsetX: 15, - offsetY: 20, - }; - - const first = extendRgbaImage(picture, options); - const second = extendRgbaImage(picture, options); - - assert.deepEqual(picture.rgba, before); - assert.deepEqual(first.rgba, second.rgba); -}); - -const SOURCE: SourceConfig = { - aspectRatio: '1:1', - padding: 0, - extend: false, - vignette: true, - vignetteDistance: 50, - vignetteHardness: 50, - vignetteSquare: true, -}; - -const VIGNETTE = { - vignetteDistance: SOURCE.vignetteDistance, - vignetteHardness: SOURCE.vignetteHardness, - vignetteSquare: SOURCE.vignetteSquare, -}; - -test('without extend the vignette is applied under the frame', () => { - const source = rgbaFixture(60, 40); - const processed = processSourceImage(source, SOURCE); - const expected = frameRgbaImage(applyVignette(source, VIGNETTE), { - aspectRatio: 1, - padding: 0, - }); - - assert.deepEqual(processed.rgba, expected.rgba); - // The frame is pure white margin the vignette never reached. - assert.ok(isWhite(processed, 30, 0)); -}); - -test('with extend the vignette is applied on top of the extended frame', () => { - const source = rgbaFixture(60, 40); - const processed = processSourceImage(source, { ...SOURCE, extend: true }); - const expected = applyVignette( - frameRgbaImage(source, { aspectRatio: 1, padding: 0, extend: true }), - VIGNETTE - ); - - assert.deepEqual(processed.rgba, expected.rgba); - - // The vignette now reaches into the corners the extend filled in: they are - // faded well toward white, while the centre is left alone. - const plain = frameRgbaImage(source, { - aspectRatio: 1, - padding: 0, - extend: true, - }); - const red = (image: typeof plain, x: number, y: number) => - image.rgba[(y * image.width + x) * 4]; - - assert.ok(red(processed, 0, 0) > red(plain, 0, 0) + 100, 'corner faded'); - assert.equal(red(processed, 30, 30), red(plain, 30, 30), 'centre untouched'); -}); diff --git a/tests/register.mjs b/tests/register.mjs deleted file mode 100644 index 8f79fe0..0000000 --- a/tests/register.mjs +++ /dev/null @@ -1,26 +0,0 @@ -// The source uses extensionless relative imports (what Next/TypeScript resolve -// against). Node's ESM resolver needs the real filename, so retry with the -// extensions the repo actually uses instead of rewriting every import. -import { registerHooks } from 'node:module'; - -const SUFFIXES = ['.ts', '/index.ts']; - -registerHooks({ - resolve(specifier, context, nextResolve) { - try { - return nextResolve(specifier, context); - } catch (error) { - if (!specifier.startsWith('.')) throw error; - - for (const suffix of SUFFIXES) { - try { - return nextResolve(specifier + suffix, context); - } catch { - // try the next candidate - } - } - - throw error; - } - }, -}); From 77a416685017d0b5f3fecda2897d036baedc7208 Mon Sep 17 00:00:00 2001 From: javierbyte Date: Sat, 5 Sep 2026 01:30:23 +0200 Subject: [PATCH 3/5] Add SVG icon file --- app/icon.svg | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/icon.svg diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..bfcfa2f --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From a6228361bb29dbf042679e8a2f9b001f7b58f960 Mon Sep 17 00:00:00 2001 From: javierbyte Date: Sat, 5 Sep 2026 02:00:18 +0200 Subject: [PATCH 4/5] Improvements --- app/globals.css | 18 +---- app/layout.tsx | 23 +++++- lib/pintr-core/utils/extendRgbaImage.ts | 7 +- lib/pintr-core/utils/resizeRgbaImage.ts | 102 +++++++++++++++++++----- main.ts | 5 +- public/fonts/Brutalita-Medium.woff2 | Bin 0 -> 3736 bytes public/fonts/Brutalita-Regular.woff2 | Bin 0 -> 3692 bytes 7 files changed, 113 insertions(+), 42 deletions(-) create mode 100644 public/fonts/Brutalita-Medium.woff2 create mode 100644 public/fonts/Brutalita-Regular.woff2 diff --git a/app/globals.css b/app/globals.css index 6454cad..6ef9258 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,17 +1,3 @@ -@font-face { - font-family: 'Brutalita'; - src: url('https://brutalita.com/font/Brutalita-Regular.woff2') format('woff2'); - font-weight: 400; - font-display: swap; -} - -@font-face { - font-family: 'Brutalita'; - src: url('https://brutalita.com/font/Brutalita-Medium.woff2') format('woff2'); - font-weight: 500; - font-display: swap; -} - body { touch-action: pan-y; margin: 0; @@ -48,7 +34,7 @@ a:focus { body, html, button { - font-family: 'Brutalita', Courier, monospace, sans-serif; + font-family: var(--font-brutalita), Courier, monospace, sans-serif; letter-spacing: var(--letter-spacing); font-weight: 400; font-style: normal; @@ -143,7 +129,7 @@ body.-dragging::after { /* normal text */ --font-size: 1rem; --font-weight: 400; - --letter-spacing: -1%; + --letter-spacing: -1px; /* small text: 4/5 of normal, medium weight */ --font-size-small: 0.88rem; diff --git a/app/layout.tsx b/app/layout.tsx index 0f609cd..765c874 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,28 @@ import type { Metadata, Viewport } from 'next'; +import localFont from 'next/font/local'; import './globals.css'; +// Self-hosted so no third-party origin sees a visit (see /privacy), and routed +// through next/font so the URLs pick up the /pintr basePath automatically — +// plain @font-face in globals.css does not. +const brutalita = localFont({ + src: [ + { + path: '../public/fonts/Brutalita-Regular.woff2', + weight: '400', + style: 'normal', + }, + { + path: '../public/fonts/Brutalita-Medium.woff2', + weight: '500', + style: 'normal', + }, + ], + display: 'swap', + variable: '--font-brutalita', +}); + const TITLE = 'PINTR - Create plotter-like line drawings from your images'; const DESCRIPTION = 'Create plotter-like line drawings from your images.'; @@ -52,7 +73,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/lib/pintr-core/utils/extendRgbaImage.ts b/lib/pintr-core/utils/extendRgbaImage.ts index c3ef7b4..cb56f26 100644 --- a/lib/pintr-core/utils/extendRgbaImage.ts +++ b/lib/pintr-core/utils/extendRgbaImage.ts @@ -54,10 +54,11 @@ export function extendRgbaImage( const source = picture.rgba; // A row and a column of zeros ahead of the picture so the four-corner lookup - // needs no bounds tests. Every entry sums at most 100M bytes, well inside - // Uint32, so the sums stay exact and the fill stays deterministic. + // needs no bounds tests. The largest entry is width * height * 255, which + // overflows Uint32 past ~16.8 MP, so the prefix sums are held as Float64 — + // exact for integers well beyond the 100 MP validateRgbaImage allows. const satWidth = width + 1; - const sat = new Uint32Array(satWidth * (height + 1) * 4); + const sat = new Float64Array(satWidth * (height + 1) * 4); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { diff --git a/lib/pintr-core/utils/resizeRgbaImage.ts b/lib/pintr-core/utils/resizeRgbaImage.ts index b497f18..fc9769c 100644 --- a/lib/pintr-core/utils/resizeRgbaImage.ts +++ b/lib/pintr-core/utils/resizeRgbaImage.ts @@ -27,32 +27,92 @@ export function resizeRgbaImage( const height = Math.max(1, Math.round(image.height * scale)); const rgba = new Uint8ClampedArray(width * height * 4); - // Pixel-center bilinear sampling is deterministic and deliberately lives in - // utils; the browser may keep drawImage when matching its current pixels. + if (scale >= 1) { + // Enlarging: every output pixel sits between four source pixels, so pixel- + // centre bilinear is the right filter and is deterministic. + for (let y = 0; y < height; y++) { + const sourceY = (y + 0.5) / scale - 0.5; + const y0 = Math.max(0, Math.floor(sourceY)); + const y1 = Math.min(image.height - 1, y0 + 1); + const fy = Math.max(0, sourceY - y0); + + for (let x = 0; x < width; x++) { + const sourceX = (x + 0.5) / scale - 0.5; + const x0 = Math.max(0, Math.floor(sourceX)); + const x1 = Math.min(image.width - 1, x0 + 1); + const fx = Math.max(0, sourceX - x0); + const target = (y * width + x) * 4; + + for (let channel = 0; channel < 4; channel++) { + const topLeft = image.rgba[(y0 * image.width + x0) * 4 + channel]; + const topRight = image.rgba[(y0 * image.width + x1) * 4 + channel]; + const bottomLeft = image.rgba[(y1 * image.width + x0) * 4 + channel]; + const bottomRight = image.rgba[(y1 * image.width + x1) * 4 + channel]; + const top = topLeft * (1 - fx) + topRight * fx; + const bottom = bottomLeft * (1 - fx) + bottomRight * fx; + rgba[target + channel] = toUint8Clamp(top * (1 - fy) + bottom * fy); + } + } + } + + return { width, height, rgba }; + } + + // Reducing: average the whole source rectangle each output pixel covers, + // weighting the partial pixels at its edges. Bilinear only ever reads a 2x2 + // neighbourhood, so past ~0.5x it drops most of the source and aliases — + // and PINTR traces that aliasing into real lines. Framing reaches 0.28x at + // padding 25 with a tall ratio, well inside the range that matters. + const spanX = image.width / width; + const spanY = image.height / height; + for (let y = 0; y < height; y++) { - const sourceY = (y + 0.5) / scale - 0.5; - const y0 = Math.max(0, Math.floor(sourceY)); - const y1 = Math.min(image.height - 1, y0 + 1); - const fy = Math.max(0, sourceY - y0); + const top = y * spanY; + const bottom = (y + 1) * spanY; + const firstRow = Math.floor(top); + const lastRow = Math.min(image.height - 1, Math.ceil(bottom) - 1); for (let x = 0; x < width; x++) { - const sourceX = (x + 0.5) / scale - 0.5; - const x0 = Math.max(0, Math.floor(sourceX)); - const x1 = Math.min(image.width - 1, x0 + 1); - const fx = Math.max(0, sourceX - x0); - const target = (y * width + x) * 4; + const left = x * spanX; + const right = (x + 1) * spanX; + const firstColumn = Math.floor(left); + const lastColumn = Math.min(image.width - 1, Math.ceil(right) - 1); + + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + let total = 0; + + for (let sourceY = firstRow; sourceY <= lastRow; sourceY++) { + const weightY = + Math.min(sourceY + 1, bottom) - Math.max(sourceY, top); + if (weightY <= 0) continue; - for (let channel = 0; channel < 4; channel++) { - const topLeft = image.rgba[(y0 * image.width + x0) * 4 + channel]; - const topRight = image.rgba[(y0 * image.width + x1) * 4 + channel]; - const bottomLeft = image.rgba[(y1 * image.width + x0) * 4 + channel]; - const bottomRight = image.rgba[(y1 * image.width + x1) * 4 + channel]; - const top = topLeft * (1 - fx) + topRight * fx; - const bottom = bottomLeft * (1 - fx) + bottomRight * fx; - rgba[target + channel] = toUint8Clamp( - top * (1 - fy) + bottom * fy - ); + for ( + let sourceX = firstColumn; + sourceX <= lastColumn; + sourceX++ + ) { + const weightX = + Math.min(sourceX + 1, right) - Math.max(sourceX, left); + if (weightX <= 0) continue; + + const weight = weightX * weightY; + const source = (sourceY * image.width + sourceX) * 4; + red += image.rgba[source] * weight; + green += image.rgba[source + 1] * weight; + blue += image.rgba[source + 2] * weight; + alpha += image.rgba[source + 3] * weight; + total += weight; + } } + + const target = (y * width + x) * 4; + rgba[target] = toUint8Clamp(red / total); + rgba[target + 1] = toUint8Clamp(green / total); + rgba[target + 2] = toUint8Clamp(blue / total); + rgba[target + 3] = toUint8Clamp(alpha / total); } } diff --git a/main.ts b/main.ts index 155b438..fd9a47f 100644 --- a/main.ts +++ b/main.ts @@ -46,7 +46,10 @@ export type configType = { const STROKE_MIN = 0.5; const STROKE_MAX = 20; const STROKE_POWER = 2; -// Mirrors the #strokeWidth input's defaultValue; lands on the historical 1.5px. +// Mirrors the #strokeWidth input's defaultValue; lands on 3.62px. Heavier than +// the 1.5px this used to default to — and because strokeWidth also sets the +// feedback erase radius, it changes which lines get picked, not just their +// weight. const STROKE_SLIDER_DEFAULT = 40; function strokeWidthFromSlider(value: number): number { diff --git a/public/fonts/Brutalita-Medium.woff2 b/public/fonts/Brutalita-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..75694a730741b4fc9e18781a355b1f936bf9054f GIT binary patch literal 3736 zcmV;J4rlRqPew9NR8&s@01lV{3IG5A06mZZ01it40RR9100000000000000000000 z0000D?J5Rf07L;c0we>21V3zaVP_Hq1_g$72Ot~RJR=dp#sPrJ*hThVk{e?{4Qe$z zsUV`eO4^8J27{7gK4Pc_ef8u2?y2vL=q2M(Cr(-2Y1{cu?fPcT`uU?BA zw~p3)S9n7x)u@j`xmDET`N;Y*lk5)8E1?X&!28zvw6*(>NtiMI7=(&(|4(oIT9^%V zh_JV&W_p2pZx4u6073W&3#fp7o{uq!a5?3oY^1yU{VX${UMVe6pIaH~N)b@llJb@J z0bv1PKtVi#LH2Br|22^R^CznDB?o-TMZV;rUkGf{cb|(t`UZIbz?pw_DbfiiRnP3X zMFb0AyH3=F;NLy+<1@segPs97xoV@yY_Zzx4yViQ@%sFMU?`j)iN=Jqc}+cXL(Upb<=kJu=)IfU??1kDuh!P z+bljR0#p^E>JbQ@OD0z+Rceh^r#E1WAJPAABOs^&h%ljy3#l~SFfH40JwFJeI7zd- zD66_@yM7p_d0DspIIsJ8zaNQlA(b}P`4Ceswe>OAKKFgyoA4IA4e!9a@E*JmAHaw3 z5qu1vz^CvT=#~J4n+bk635g=t&7PmL| zAMRrw!K>sw;dkb57B~bGg^h)uMWk3MULxU27NiN&Hl^pMAIfN$aX-_W`6_Ez*4ym9 zIhvf4xn%Bxyv}(KhzZhK(hIW2vdwa?yhwgZ!BI3(yi<-<0oA2$tUj$-tTkvU-FUrN zKh6*|>@&uU4@_fCU(Ekm8e5K9Yg%8}mf7ps4?0>p<~UW(sm`;xID_|X&p$&FCCW|H zQaicA$aepJkSIGC-Cq0!=+zfD$M%pQ2<-K5%8y&OE;Nivr)wL7tpfmYO<#Mk2_X5Y zQAA6N@q2`Py}^aw4P6 zzgDPV_ky&AiwY4Tnu%2GS4=kbW#-jwi7f!nlZ&rmx*PvhF_fNJqI_}Ceh&NjITF+V z2SUg)kTE!L7=~6>AFg&>D0;Pe*qwF@bYGphK7^>AUKraG!C8)kI0n~!xSttr4k2QQ zd!V?v3f^r*4{`?Lz*{a_Ce zji@M;88&935e^dkvq%N+MRU9qm{Mep@p6bE4g+oi@5U(L=;|mklbBJ>*E3x@U7>6~-ceQgsq7}{t(kHoPiW+w=;q0ugYgRcw(^8G-=JXSxmU?lXV8^oBS1!(HR~9NJr$cZoMl_gS3tI%Aj*w zw?ftK3923O?%c{N%^u%u#IQ)Rn{7o%yLA#W3KnAU=;Lf;gOgYx&}c|*5ls!h)Gj2+H19ZO|R1trBX~z^a93hg;L)47!Lv8 zLXQ+gA2z+Hb0`O|+HTZwgf=KiNm^T+@h|;e`lPK*!i{C8X{{=%N684pM#Q(soMs~!C*sSDo z+Vx0WypJQb`vek2EMGMOwcP`HG~7VS*Ilz0e-ILUgIv$VFdWkwSev6?ha!C>D~oic z2;a|(BBX7D3dL#Ft5(aajzqQ6AlC^JJp6J-m(^o9b454iD#Iv-VHH-Jbf;2nsf$Uo zPf56EAKC^DBp0j$%cm)aa#KbxiPiGN;N#i&8RL{&uf&us8EyHB)bZh!%cAw09AOuY ztDuRXD+Au;`oTNi;2M38>&XYGWW?>0$jBNP|2|_RJY|$g6htZOo&Qo^zD_>YBcapUOT1lMIjR>?yn%WL9WIlUW<`jx zX#gx$e8v+IxSn^GZMl9sq!Do zlhOGr+n*hUt$xIkX!%pEpurRQYoyqq99S-n*jI&??Dm^TaqA zmK)3v=hJ#5TK4My#=BY0nxqakl<;eEZYC;)ZlfgAUG5lgyP@S&|G%1>A8C1FN2Cb} zKWFQ9vttyE55AJR-L=OF*b<#IYgN^jknX(oIZHxM(=>^37;1 zmAV1B#T)0=qhL+)3lqjs2P+tcqhjUTj`)BdZG5FaqRyKA7(Sxaw@NqVu8ZbqQeU^3 zos-?PR+V`A-mcpfh#Cnrbcm(P^oVYTrgn@-l2XsK5g`W)QeBOmW_C+e`zP>OvY9)H z#${KVmkkG=j>2@TYlnIi8X(pk_9t%=2Z4O1a?CzI=|56dB5vP7Ny%H$zC4LMQWC@1 zvZE(mB6z&%aMl9yKXJg-bjWKC@u>ZwVzFn6A!OV^s&%{5%*Jcm&ey|)tNBv9BB+Rh zBba!#xintp0-MO}B2lp*@BYYUkV3RAsgw1Kj5A01bM;C|=k=fqLdB|K_HTF$^ECmQ z7ZY+B!jX2AN+-2i{j{S%DTbDlAh)yNG7jKy7LZh6wRPLOyH6+$)~z{=?uYTH{0FOfM{EUW39C3*x}8tgv1U|7Vu={|4En`6 zAzsjGM$JN7E*B^y_f!{E^Py&xCaSMAVWH{CVkJodKFkd^1MXr#K20+)a3R<-u{p50 zlXe`y&>+RiTRlf&S*Qj00)&cIoT2nY#=xa2e?#CPu5gxWsk}O%Jn*);}zyjrlk1P zuh^TiD0ZIwca6;6!0sUB#h3UozvK$>W{$hGT_w4-BS?0LRu2$q%ZEFYVVBPCG{$j| zl_9gQ0H>*i4NOo9u%0veEjiP4c8>3YIinM zzK`+nfvF~KGV(mh%o^+Rr<_@1g-^>ityj5jj)zE_A90;5*eO*-`lw5Oh(ml?mW8y( z&By7pkanLX=Zxc*sa3G$QW-V)AEEA|phmlh8D?^t5wrjJX6I+U1;pOz5U@Ll#lssI z?hAG)P0@!(2x0ni%qBPYLY1a%6d74qUjfo*n3Eyv5gC(L*_0+^Q<{k>udAMQtd3k5 z!o=T?BrdXSmT5+taY+KS~#Y~a(F*h@&KAI!4CjH4rB)aCchjBe1Sa>a630D(e@J( zJY*yO=vM^*U!e~G009870dE3OJh&m)3=%md>mS1O*5R5ELLNK$x1L06_u5)NBO^3J|8^ zAt*q2?zjX7WFGtFT9HmG)`s~l#i%Xg{4_RW|N03<3Fs^UL{c0pYy16KrAig)OD3&3 zKNL8>b1i%?>k}YbU%@}TE%=+cseiGtnNouOKVYVQ+LSFVtNxsKjk193Y~2akEdT&^ CBm;H; literal 0 HcmV?d00001 diff --git a/public/fonts/Brutalita-Regular.woff2 b/public/fonts/Brutalita-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..39e8d770e48b8e97235c85e31ae8e710087594a9 GIT binary patch literal 3692 zcmV-y4wLbBPew9NR8&s@01j*b3IG5A06m-l01hAk0RR9100000000000000000000 z0000D?Jfpj07L;c0we>21V3zaVP_Hq1_g$G2Ot~eJR_1#)+!N|n&Bg=HQQzXF}XPe zzk{09PlA(*suw9|tj2aubj(Q(7A?%4N5dr?;gTp7d~@@d3cb3}jf9=TfBTU|zryVs z8M}gn-ZDal+$NIU{aS)07*gOKU})i0!bdu^iRcHw;5JhwWlSncaEWrOY~Y`mTizDZ288%iCq{dSRq?E$i~WN{6-${=$pwh2 z&gzq*34jfRSMK-II`_pb^|$mh=`#YNzyKp9$(7F$Xbgg3Uabuq1YHfme=RcKe62PI zoLTA`>Tc=K^llFbrT@Z8`Fj24Cm+FtsL<7AzK|u}{`MWBoZ&*K;OMGu+O8kQX8>VGDuIC3~6oVwq@}jKjrtSJ+oaSYP+kTwa{d#}C zzkg|2xmZzIRb5jnnP&5;U9Vn+C?jpne!UIFU%u=4K^VnJn&m}V)eWlu-5wALf)NzM zNn{F@MrSZtYz~*l7YIdSiBu+6C{=2WmeA=9Mw8iMwb>m`m)qm@`2i4u5fsA-lA;-w z;{{QY6;-3v=?zAc*FGK6|^0fFP35^q8log3;(ob^t`eM_z`O>aU>(rg?`}T`7 zA~WV>+%i!B{bQe4Ww-g$RfGcX1RCSCWq=$)Tdv0P8l-1eG{|j1b_f|QV$Jy1%R^SV zKM%Kc=zgV;Jje%QjWQlW?zaYBvzt_hkmB?|Qs#ws^^pu+fz+|s)L|nKauCv-IK=b9 zwig0jCy1Ssf%q7L!@q|vY6ZEA`946keI+^428aSQQ)Sy($ll-Go@%kNg>NFGS*%6K zO84iJ>A_eBLbJ8(Tv{ViTzQz|0lNaEUTh1NUJZX!IB|%$8u|5WDnv-}cxaNCBjXX$ zPDap%h=XyDWWGwL&VcB8cWyq#$fEMNd^S2PTp*3_>1PzSve8O%e8)!Y0beZF-3aOZ zmirY#^2Hh+J0#sw_VRB}cZMdmB3KT68s{rF^AD zBQ)OB7iKioXo0?ZjXes6r49+n&2ioA!Xg*Sew9WcD3!~8@42MHbmMlz_=ry*{-Aa4y;1pblt z0IQ1y01_SPnCSVZhG>!fB+yoLP$XZ zp_g7;M(ObHOT+Bpup-yl2CjVAUJ5%Gro*@1I-155lEynZteVg3HrtKNxD{TVZVd~i zhI#Ey>Nbb*VL?ftul}dR^Hd9W+UrZB9%@ENp)aT7)$FRw1f-H4dOPM93+W5^yG={t z*MjRKj@4F*izfl}DQoXH%<@voYb^=R@+3Oj(nu-tT3(xiJ+ektOu(on5q4F(2jX`3*Y%M_U9x#*gh<Ch{=e*89@&WPuDUmazOEYQsbe}g%Phn}b3s`5d=UvA9$7f|r(fJXF z?OKPHJf%lC{sT$7J@>j*b_B?x)Y&VX?`<>0+4f}buFeQGh#X!bO)?TA^+gavUOM#~ zm~9#pz>r!xo3S{x7=V9qsS$F}+Igu+=8j%snp6ZL4CNm_xFK71r>nwkBo*@3Nh>~7Ci~+C=68Uwsvt?h)547?dIUzl3)s8_;sMS% z?ko9sz$Y>$S0fB-xp(pxSmNF@JU$l03TP$e~dn!7x5Nzo-5r%pE~!* zF4IaNS(_GfQPq)wzlF=O;7umh?eBOah!<*4tW~9uC+o<;mcptpR*S;!^*PuAZI##v zWzeqx$EpEZD@g1^m{#2JJmgZnL20hulxjoeJ=|m^pd73ln$;W+lKDLd{j67Ni`+od z?uQGz23PNX1Mj`Wl)U!lbxC3WK{=oFo6R#)1o9q&Cfd`hE}cXFo+ej3G`S`d@Ybzn zLc@iCR^f#}yIV{-QK-^!dU9;pl-teW-Brw685HQ$gfNje*kUJHL*mqhKC*djKQ1vwrH^I;{8AbnWO2US+0a&jw!Is}Q7C zFL@qGH3PCH4l+t3mhP71?pIIq;8^up^9B^eD_FJzA8EKMenuSoa(9nrQd#Hc40!X@ zgGe{5r|+f6LLE0Su?GM%brE=UqMI5jlWCg16x8izMg$W@52zTeiMz{F&AVA9f7)H# z3IcVSnXR(Pye72d&0<~?>fE__hdFt1c1{x_a5)VtGr%)gL8+Mj%!af|F0g&MS#KLhBKJuhE;MPBP6j-f2>v^FTslN}t_^BBF)k$n+ zXacSd2{}70!bZ%?MG1HRO_XPMZvAuD6x=-YtmlOqp;>HhfH@~CH?>0V0=L+Ng2a~Gz Date: Sat, 12 Sep 2026 05:00:57 +0200 Subject: [PATCH 5/5] Better font --- app/globals.css | 34 ++++++++++++++++++++++++---------- app/layout.tsx | 27 ++++----------------------- app/page.tsx | 6 +++--- app/privacy/page.tsx | 4 ++-- 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6ef9258..37a5629 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,25 @@ +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-SemiBold.woff2') + format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + body { touch-action: pan-y; margin: 0; - padding: 0.25rem 0 2rem; + padding: 0.25rem 0 3rem; + letter-spacing: -0.01em; } .skip-link { @@ -34,11 +52,10 @@ a:focus { body, html, button { - font-family: var(--font-brutalita), Courier, monospace, sans-serif; - letter-spacing: var(--letter-spacing); + font-family: 'Brutalita', Courier, monospace, sans-serif; font-weight: 400; font-style: normal; - font-size: 24px; + font-size: 22.4px; } h1 { @@ -71,7 +88,7 @@ button { appearance: none; -webkit-appearance: none; margin: 8px 16px; - padding: 10px 16px 8px; + padding: 8px 16px; border: 3px solid #000; display: block; background: #ecf0f1; @@ -79,7 +96,6 @@ button { color: #000; font-size: var(--font-size-small); font-weight: var(--font-weight-small); - letter-spacing: var(--letter-spacing-small); } button:hover { cursor: pointer; @@ -129,12 +145,10 @@ body.-dragging::after { /* normal text */ --font-size: 1rem; --font-weight: 400; - --letter-spacing: -1px; /* small text: 4/5 of normal, medium weight */ - --font-size-small: 0.88rem; - --font-weight-small: 500; - --letter-spacing-small: normal; + --font-size-small: 1rem; + --font-weight-small: 600; } .inline { diff --git a/app/layout.tsx b/app/layout.tsx index 765c874..05004fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,28 +1,6 @@ import type { Metadata, Viewport } from 'next'; -import localFont from 'next/font/local'; - import './globals.css'; -// Self-hosted so no third-party origin sees a visit (see /privacy), and routed -// through next/font so the URLs pick up the /pintr basePath automatically — -// plain @font-face in globals.css does not. -const brutalita = localFont({ - src: [ - { - path: '../public/fonts/Brutalita-Regular.woff2', - weight: '400', - style: 'normal', - }, - { - path: '../public/fonts/Brutalita-Medium.woff2', - weight: '500', - style: 'normal', - }, - ], - display: 'swap', - variable: '--font-brutalita', -}); - const TITLE = 'PINTR - Create plotter-like line drawings from your images'; const DESCRIPTION = 'Create plotter-like line drawings from your images.'; @@ -73,7 +51,10 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + + + + {children} ); diff --git a/app/page.tsx b/app/page.tsx index 01eedce..f2d3563 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -76,7 +76,7 @@ export default function Page() { step="1" min="0" max="100" - defaultValue="35" + defaultValue="42" />
@@ -128,7 +128,7 @@ export default function Page() { step="1" min="0" max="100" - defaultValue="40" + defaultValue="35" />
@@ -329,7 +329,7 @@ export default function Page() {
  • Download. Your pictures never leave your computer.
  • - More information and source available on{' '} + {'More information and source available on '} Github.

    diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 9fc631d..2817918 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -187,8 +187,8 @@ export default function PrivacyPage() {
  • - The typeface is served from this site, so no font or other - third-party CDN sees your visit. + The Brutalita typeface is loaded from brutalita.com, so that origin + sees your browser’s font request.