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..2500537 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, 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.
+
[](https://javier.xyz/pintr/)
## Examples
diff --git a/app/globals.css b/app/globals.css
index f3e2909..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: -1px;
+ font-family: 'Brutalita', Courier, monospace, sans-serif;
font-weight: 400;
font-style: normal;
- font-size: 24px;
+ font-size: 22.4px;
}
h1 {
@@ -77,7 +94,8 @@ button {
background: #ecf0f1;
white-space: nowrap;
color: #000;
- font-size: 1rem;
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-small);
}
button:hover {
cursor: pointer;
@@ -123,6 +141,14 @@ body.-dragging::after {
:root {
--sizeh: calc(1080px / 2);
--sizew: calc(1080px / 2);
+
+ /* normal text */
+ --font-size: 1rem;
+ --font-weight: 400;
+
+ /* small text: 4/5 of normal, medium weight */
+ --font-size-small: 1rem;
+ --font-weight-small: 600;
}
.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/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
diff --git a/app/layout.tsx b/app/layout.tsx
index f01aeb2..05004fa 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,15 +1,6 @@
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 +51,11 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
+
+
+
{children}
-
);
}
diff --git a/app/page.tsx b/app/page.tsx
index 79fb3ae..f2d3563 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="42"
/>
@@ -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="35"
/>
-
-
-
-
-
-
-
-
-
-
- Select an image — images with a face, white or light backgrounds
- and good contrast work better.
-
-
Play with the controls.
-
Download. Your pictures never leave your computer.
-
-
- 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. */}
+
+
+
+
+
+
+
+
+
+ Select an image — images with a face, white or light backgrounds
+ and good contrast work better.
+
+
Play with the controls.
+
Download. Your pictures never leave your computer.
+
+
+ {'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..2817918
--- /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 Brutalita typeface is loaded from brutalita.com, so that origin
+ sees your browser’s font request.
+
+
+
+
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
+
+
+
+ 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.
+
+
+ The drawing starts immediately and fills in line by line while you
+ watch.
+
+
+ Adjust the controls. Every change redraws right away.
+
+
+ Tap Export to share the result as PNG or SVG.
+
+
+
+
+ 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..a18555c 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,101 @@ 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;
-
- 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;
- }
+ let running = false;
+ let stopped = false;
// 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();
- 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.
+ // 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;
- 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.
+ // 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) {
- 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.
+ // 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 (!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.
+ // Permanently halt this instance 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..1cc14c8
--- /dev/null
+++ b/lib/pintr-core/README.md
@@ -0,0 +1,128 @@
+# PINTR core
+
+```ts
+import { createPintr } 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);
+```
+
+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;
+
+session.next(count): PintrBatch;
+```
+
+`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. 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:
+
+```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
+const lines = [];
+
+while (pintr.lineCount < targetLineCount) {
+ const batch = pintr.next(
+ Math.min(1000, targetLineCount - pintr.lineCount)
+ );
+ lines.push(...batch.lines);
+}
+
+writeCoordinates(lines);
+```
+
+## 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/createPintr.ts b/lib/pintr-core/createPintr.ts
new file mode 100644
index 0000000..2dff4d2
--- /dev/null
+++ b/lib/pintr-core/createPintr.ts
@@ -0,0 +1,181 @@
+import { tweenValue } from './math';
+import { createRandom } from './random';
+import { scanLine } from './scan';
+import { createSource } from './source';
+import type {
+ PintrBatch,
+ PintrConfig,
+ PintrImage,
+ PintrLine,
+ PintrPoint,
+ PintrSession,
+} from './types';
+import { PintrError } from './types';
+
+const MAX_PIXELS = 100_000_000;
+
+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');
+ }
+}
+
+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(input.config);
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
+ throw new PintrError('seed must be a uint32');
+ }
+
+ // 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],
+ [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, 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;
+
+ // 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];
+ 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 = 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 (generatedLineCount % updateSampleRate === 0) {
+ source.erase(pendingLines, config.strokeWidth * 1.5, feedbackAlpha);
+ pendingLines = [];
+ }
+ batch.push(drawSequenceLine());
+ generatedLineCount++;
+ }
+
+ return {
+ startLine,
+ endLine: generatedLineCount,
+ lines: batch,
+ };
+ }
+
+ return {
+ width: image.width,
+ height: image.height,
+ seed,
+ get lineCount() {
+ return generatedLineCount;
+ },
+ next,
+ };
+}
diff --git a/lib/pintr-core/index.ts b/lib/pintr-core/index.ts
new file mode 100644
index 0000000..f6a50db
--- /dev/null
+++ b/lib/pintr-core/index.ts
@@ -0,0 +1,10 @@
+export { createPintr } from './createPintr';
+export { PintrError } from './types';
+export type {
+ PintrBatch,
+ 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..b7c9e72
--- /dev/null
+++ b/lib/pintr-core/random.ts
@@ -0,0 +1,26 @@
+const STEP = 0x6d2b79f5;
+
+// 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;
+ 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 { int };
+}
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..d3676dc
--- /dev/null
+++ b/lib/pintr-core/source.ts
@@ -0,0 +1,117 @@
+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,
+ };
+}
diff --git a/lib/pintr-core/types.ts b/lib/pintr-core/types.ts
new file mode 100644
index 0000000..9269d7c
--- /dev/null
+++ b/lib/pintr-core/types.ts
@@ -0,0 +1,38 @@
+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 PintrSession = {
+ width: number;
+ height: number;
+ seed: number;
+ lineCount: number;
+ next(lineCount: number): PintrBatch;
+};
+
+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..cb56f26
--- /dev/null
+++ b/lib/pintr-core/utils/extendRgbaImage.ts
@@ -0,0 +1,183 @@
+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. 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 Float64Array(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..fc9769c
--- /dev/null
+++ b/lib/pintr-core/utils/resizeRgbaImage.ts
@@ -0,0 +1,120 @@
+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);
+
+ 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 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 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 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);
+ }
+ }
+
+ 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 `
`;
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 `