Skip to content

Repository files navigation

Coursereader

Coursereader is a dependency-free TypeScript library that makes live HTML resemble photocopies, microfilm, archival scans, photographed books, and degraded reproductions.

The effect preserves:

  • Selectable text
  • Links
  • Semantic HTML
  • Images and diagrams
  • Forms and controls
  • Screen-reader content
  • Responsive layout

It does not rasterize the webpage.

Live demo

The playground is deployed to GitHub Pages: https://cmahnke.github.io/coursereader/

The API reference is generated from the JSDoc comments with TypeDoc and published next to it: https://cmahnke.github.io/coursereader/docs/

Installation

npm install @projektemacher/coursereader

The package is ESM-only and ships TypeScript declarations. Published tarballs also include the compiled playground at playground/dist/index.html.

Quick start

import { courseReader } from "@projektemacher/coursereader";

const page = courseReader("#document", {
  preset: "microfilm-journal",
  seed: 1971,
});

The supplied element is wrapped, not converted into an image.

<article id="document">
  <h1>Document title</h1>
  <p>The original text remains selectable.</p>
</article>

Destroy the effect and restore the original element:

page.destroy();

Use page.update() to change settings without touching the stored preset:

page.update({
  artifacts: {
    grain: 0.2,
    verticalStreaks: 0.4,
  },

  border: {
    enabled: false,
  },
});

Built-in presets

Preset Appearance
clean Neutral defaults with only the copier frame visible
book-photo Photographed book page with mild rotation and uneven lighting
bound-volume book-photo with higher contrast and edge wear
microfilm-journal Heavy grain, scratches, streaks and a dark frame
early-office-bitonal Harsh two-tone office-copy appearance
xerox-zine Stronger bitonal variant with a worn frame
analog-gray-copy Quantized eight-level analog office copy
digital-gray-copy Cleaner 32-level digital copier appearance

Gutters and responsive layout are not part of scan presets. They belong to viewer layouts and gutter styles.

Custom presets

A preset is plain JSON: a DeepPartial<ScanSettings> object that may inherit another preset with extends. Save it as a file next to your app:

{
  "extends": "analog-gray-copy",
  "exposure": {
    "contrast": 1.6
  },
  "tone": {
    "mode": "quantized",
    "levels": 4,
    "dither": "random",
    "ditherAmount": 0.6
  }
}

A single JSON document can also hold a map of presets:

{
  "newsprint": {
    "extends": "analog-gray-copy",
    "artifacts": { "grain": 0.35 }
  },

  "blueprint": {
    "extends": "digital-gray-copy",
    "paper": { "tone": 0.72 }
  }
}

Register the presets in your app before creating pages or viewers — the preset option refers to the registered name:

import { presets } from "@projektemacher/coursereader";

// A single-definition file needs an explicit name:
await presets.fromUrl("./presets/newsprint.json", "newsprint");

// A map file registers every entry:
await presets.fromUrl("./presets/office.json");

// Both also work with plain fetch and the registry directly:
const response = await fetch("./presets/office.json");

presets.import(await response.json());

Then reference the preset by name:

courseReader("#document", {
  preset: "newsprint",
});

presets.fromUrl() fetches the document over HTTP, registers it and returns the loaded preset names. Unknown or circular preset names throw when they are resolved.

Presets can also be defined in code, as shown in Defining presets in code.

Enhancement modes

enhancement selects the rendering strategy:

courseReader("#document", {
  preset: "analog-gray-copy",
  enhancement: "auto",
});
  • "auto" uses SVG filters where supported and otherwise falls back to CSS.
  • "svg" requests SVG filters.
  • "css" forces the basic HTML/CSS fallback.

The CSS fallback retains:

  • Grayscale
  • Brightness
  • Contrast
  • Basic blur
  • Paper tone
  • Grain
  • Dust
  • Scratches
  • Streaks
  • Dark borders
  • Uneven exposure
  • Responsive gutters

It cannot perform a strict per-pixel threshold. Bitonal and quantized modes therefore become high-contrast grayscale approximations in CSS fallback mode.

If JavaScript fails entirely, the source HTML remains normal HTML and does not depend on a canvas or generated image.

Tuning the tone

Strict bitonal mode

Bitonal mode produces an SVG-filtered black-and-white representation:

courseReader("#document", {
  preset: "early-office-bitonal",

  overrides: {
    tone: {
      mode: "bitonal",
      threshold: 0.62,
      dither: "random",
      ditherAmount: 0.1,
    },
  },
});

A higher threshold produces more black:

tone: {
  threshold: 0.7;
}

A lower threshold preserves more white:

tone: {
  threshold: 0.48;
}

Strict bitonal output is an SVG rendering effect. The underlying DOM and text are unchanged.

Quantized grayscale

Restrict the visible output to a fixed number of gray levels:

courseReader("#document", {
  preset: "analog-gray-copy",

  overrides: {
    tone: {
      mode: "quantized",
      levels: 8,
      dither: "random",
      ditherAmount: 0.32,
    },
  },
});

Suggested values:

Levels Appearance
2 Black and white
4 Very coarse gray
8 Coarse analog-copy appearance
16 Moderate grayscale
32 Cleaner digital copier
256 Effectively continuous

Random dithering

Random-looking dithering uses seeded SVG turbulence before thresholding or quantization:

courseReader("#document", {
  preset: "analog-gray-copy",
  seed: "document-12-page-4",

  overrides: {
    tone: {
      mode: "quantized",
      levels: 6,
      dither: "random",
      ditherAmount: 0.4,
    },
  },
});

The same seed produces the same SVG noise configuration. Browser SVG implementations can still differ slightly, so this is deterministic configuration rather than guaranteed pixel-identical output across all browsers.

The seed also steers the placement of the exposure falloff, the show-through pattern, the noise tile alignment and the sheet edge shadows, so pages with different seeds produce visibly different scans even with identical settings.

Defining presets in code

Presets are plain serializable configuration objects:

import { presets } from "@projektemacher/coursereader";

presets.define("library-copy", {
  extends: "analog-gray-copy",

  exposure: {
    contrast: 1.35,
    unevenness: 0.2,
  },

  tone: {
    mode: "quantized",
    levels: 6,
    dither: "random",
    ditherAmount: 0.38,
  },

  artifacts: {
    grain: 0.25,
    verticalStreaks: 0.08,
  },
});

Use the custom preset:

courseReader("#document", {
  preset: "library-copy",
  seed: 42,
});

Update it:

presets.update("library-copy", {
  artifacts: {
    grain: 0.4,
  },
});

Export one preset or every preset:

const one = JSON.stringify(presets.export("library-copy"), null, 2);
const all = JSON.stringify(presets.export(), null, 2);

Import preset data:

presets.import({
  "machine-number-three": {
    extends: "early-office-bitonal",

    tone: {
      threshold: 0.66,
      ditherAmount: 0.14,
    },
  },
});

PresetRegistry also supports get, has, names, remove and resolve.

Responsive viewer

createCourseReaderViewer renders multiple pages and switches between single-page and spread layouts as media queries match.

import { createCourseReaderViewer } from "@projektemacher/coursereader";

const viewer = createCourseReaderViewer("#reader", {
  pages: ["#page-1", "#page-2", "#page-3", "#page-4"],

  preset: "microfilm-journal",
  seed: 1971,

  layouts: [
    {
      query: "(max-width: 767px)",
      mode: "single",
      pageFit: "width",
      gutter: false,
      background: "#222",
    },

    {
      query: "(min-width: 768px)",
      mode: "spread",
      pageFit: "contain",
      gap: 0,
      gutter: "deep-bound-volume",
      background: "#222",

      spread: {
        firstPageSide: "right",
      },
    },
  ],
});

Navigation:

await viewer.next();
await viewer.previous();
await viewer.goTo(5);

Page indices are zero-based. Use viewer.getPage() for the current index, viewer.refresh() after external changes, and viewer.destroy() to tear the viewer down.

Gutter styles

Register a reusable binding style through bindings:

import { bindings } from "@projektemacher/coursereader";

bindings.define("library-binding", {
  width: 40,
  darkness: 0.72,
  blur: 14,
  curvature: 0.6,
  highlight: 0.12,
});

Use it only in the desktop spread layout:

layouts: [
  {
    query: "(max-width: 767px)",
    mode: "single",
    gutter: false,
  },

  {
    query: "(min-width: 768px)",
    mode: "spread",
    gutter: "library-binding",
  },
];

The compositor determines the gutter edge:

left page   → right gutter
right page  → left gutter
single page → no gutter

An inline gutter configuration is also supported:

{
  query: "(min-width: 768px)",
  mode: "spread",

  gutter: {
    width: 36,
    darkness: 0.65,
    blur: 12,
    curvature: 0.5,
    highlight: 0.08
  }
}

Playground

createPlayground renders the self-contained playground UI used by the live demo: tabs, controls, sample sheets and a multi-page viewer.

import { createPlayground } from "@projektemacher/coursereader";

const playground = createPlayground("#app", {
  title: "Archive Scan",
  sections: {
    playground: true,
    viewer: true,
  },
});

// Later:
playground.destroy();

Options include title, subtitle, sampleDocument, sampleDocumentRight, viewerPages and sections.

The rendered demo exposes its own controls: tone and artifact sliders, preset, seed and enhancement selects, a frame toggle and a ring-binder toggle. In spread mode, "Pages per side" stacks extra sheets at the fore-edges and deepens the spine fold (1–16 sheets), the rotation sliders tilt each sheet manually in both directions, and the "Grayscale dithering" toggle reproduces every gray tone as random black dots through stochastic screening. A collapsed "Advanced settings" section below the live overrides exposes every remaining ScanSettings option — paper tone and texture, the full exposure curve, ink erosion, all scan artifacts, capture handling and the copier frame's darkness, thickness, roughness and sides.

API overview

Export Purpose
createCourseReaderPage(source, options) Wrap an attached element in the scan effect
courseReader Alias for createCourseReaderPage
createCourseReaderViewer(target, options) Render a responsive single-page or spread viewer
createPlayground(target, options) Render the demo playground
createToneFilter(settings, seed, id) Build a standalone SVG tone filter
createToneTable(settings, sampleCount) Build an SVG component-transfer table
presets / PresetRegistry Manage and resolve named scan presets
bindings / BindingRegistry Manage named gutter styles
builtInPresets, builtInBindings Built-in definitions
defaultSettings Baseline ScanSettings
ScanSettings Complete scan and reproduction settings
CourseReaderPageOptions, CourseReaderPage Page options and handle
CourseReaderViewerOptions, CourseReaderViewer Viewer options and handle
ResponsiveLayout, GutterOptions Layout and gutter configuration
PlaygroundOptions, PlaygroundHandle Playground options and handle

All public factories and types carry JSDoc with parameter, return and error documentation. The full reference is generated with TypeDoc and published on GitHub Pages: https://cmahnke.github.io/coursereader/docs/

Development

The repository requires Node.js 24 (matching CI) and npm.

Install dependencies:

npm ci

Useful commands:

npm run build                # Generate embedded styles and compile to dist/
npm run build:all            # Build the library and the playground
npm run validate             # ESLint, Stylelint, library and playground typechecks
npm run format               # Prettier
npm run format:check         # Check formatting
npm run dev                  # Start the playground at localhost:5173
npm run playground:dev       # Start the playground at localhost:5173
npm run playground:build
npm run docs                 # Generate the TypeDoc API reference

The library CSS lives in src/css/styles.css and src/css/playground-styles.css. scripts/generate-styles.mjs embeds it into ignored src/ts/*.generated.ts modules during prepare, build and typecheck. While editing styles, run npm run watch:styles to keep the generated modules up to date.

Project layout

src/ts/                Library TypeScript source and public types
src/css/               Stylesheet sources embedded at build time
scripts/               Style generation script
playground/            Vite playground and live demo
.github/workflows/     Validation and GitHub Pages workflows

Continuous integration

  • validate.yml runs ESLint, Stylelint, the library typecheck and the playground typecheck on pushes and pull requests to main.
  • pages.yml builds the playground and deploys it to GitHub Pages on pushes to main.

Plain HTML-only fallback

A page can use a simplified static effect without importing the library:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />

    <style>
      .archive-fallback {
        position: relative;
        isolation: isolate;
        color: #111;
        background: #eee;
        filter: grayscale(1) contrast(1.35) brightness(1.04);
        box-shadow:
          inset 14px 0 18px rgba(0, 0, 0, 0.12),
          inset -8px 0 12px rgba(0, 0, 0, 0.08);
      }

      .archive-fallback::before {
        content: "";
        position: absolute;
        inset: 0;
        z-index: 10;
        pointer-events: none;
        opacity: 0.22;
        mix-blend-mode: multiply;
        background:
          repeating-radial-gradient(
            circle at 24% 31%,
            rgba(0, 0, 0, 0.18) 0 0.5px,
            transparent 0.8px 3px
          ),
          linear-gradient(
            90deg,
            rgba(0, 0, 0, 0.12),
            transparent 10%,
            transparent 88%,
            rgba(0, 0, 0, 0.1)
          );
      }
    </style>
  </head>

  <body>
    <article class="archive-fallback">
      <h1>Original HTML</h1>
      <p>This text remains selectable and does not require JavaScript.</p>
    </article>
  </body>
</html>

This fallback intentionally does not claim exact bitonal thresholding or stochastic per-pixel dithering. It provides the general scan appearance using only HTML and CSS.

Historical photocopy eras

Terminology

“Xerox” is a trademark and manufacturer name. It is also frequently used informally for photocopying.

The presets are visual approximations, not exact simulations of specific machine models.

Actual appearance depended on:

  • Machine model
  • Drum and toner condition
  • Exposure controls
  • Original document
  • Paper
  • Maintenance
  • Copy generation
  • Halftone or screening method
  • Later scanning and compression

1930s–1950s: early xerography

Early xerographic systems were not equivalent to later automatic office copiers. Some required multiple manual or semi-manual processing stages.

Associated reproduction characteristics include:

  • Strong contrast
  • Weak tonal continuity
  • Toner grain
  • Background fog
  • Uneven solids
  • Loss of pale detail

Suggested starting point: early-office-bitonal.

1960s: practical plain-paper office copying

Automatic plain-paper copying became substantially more practical. Text and line art were generally reproduced more successfully than continuous-tone photographs.

Common archival characteristics include:

  • Darkened type
  • Lost highlights
  • Background marks
  • Uneven exposure
  • Limited photographic detail
  • Platen-edge shadows

Suggested preset: early-office-bitonal.

Analog output could appear bitonal without being a literal digital one-bit image. Toner density and paper texture could create intermediate values.

1970s: widespread office and library use

Photocopiers became common in offices, universities and libraries.

Copies may show:

  • Strong text reproduction
  • Toner expansion around characters
  • Book-gutter shadows
  • Dark platen edges
  • Dust and streaking
  • Progressive deterioration in copies of copies

For text-heavy reproductions use early-office-bitonal. For pages retaining some gray tone use analog-gray-copy. For pages later transferred through microfilm use microfilm-journal.

1980s: improved analog copying

Many machines offered better exposure control, reduction, enlargement and mixed-original handling.

Common characteristics include:

  • More stable text density
  • Coarse photographic tones
  • Grain or screening
  • Edge shadows
  • Maintenance streaks
  • Detail loss over repeated generations

Suggested preset: analog-gray-copy.

overrides: {
  tone: {
    mode: "quantized",
    levels: 6,
    dither: "random",
    ditherAmount: 0.32
  }
}

1990s: transition toward digital copiers

Digital copiers increasingly scanned an original and reproduced it through a digital print engine.

Common differences include:

  • More repeatable output
  • Cleaner backgrounds
  • Digital edge enhancement
  • Explicit halftoning
  • Multiple gray levels
  • More regular screening patterns

Suggested preset: digital-gray-copy.

2000s onward: multifunction digital devices

Scanner, copier and printer functions increasingly shared one digital imaging pipeline.

Typical properties include:

  • Higher grayscale resolution
  • Automatic background removal
  • Text/photo classification
  • Edge enhancement
  • Compression
  • Cleaner white regions
  • More consistent repeated output

Suggested preset: digital-gray-copy.

For a cleaner result:

overrides: {
  tone: {
    mode: "continuous",
    dither: "none",
    ditherAmount: 0
  },

  artifacts: {
    grain: 0.02,
    dust: 0,
    verticalStreaks: 0
  }
}

Copies of copies

Much of the familiar harsh photocopy appearance results from repeated reproduction rather than from one machine era.

Each generation may:

  • Increase contrast
  • Expand dark type
  • Remove pale detail
  • Convert gray texture into spots
  • Strengthen borders
  • Accumulate dust and marks

Example:

presets.define("third-generation-copy", {
  extends: "early-office-bitonal",

  exposure: {
    contrast: 2,
    blownHighlights: 0.4,
    crushedShadows: 0.62,
  },

  ink: {
    spread: 0.7,
  },

  artifacts: {
    grain: 0.4,
    dust: 0.24,
    edgeNoise: 0.52,
  },
});

Bitonal versus old-looking grayscale

A literal bitonal image contains only black and white output values. Use this for strict two-tone display:

tone: {
  mode: "bitonal",
  levels: 2,
  threshold: 0.6,
  dither: "none",
  ditherAmount: 0
}

Use low-level grayscale when paper and toner variation should remain visible:

tone: {
  mode: "quantized",
  levels: 4,
  threshold: 0.6,
  dither: "random",
  ditherAmount: 0.3
}

Limitations

  • SVG filter rendering can vary between browsers.
  • SVG filters on very large DOM trees may be expensive.
  • Random dithering is an SVG turbulence approximation.
  • Floyd–Steinberg error diffusion is not possible in live DOM mode.
  • CSS fallback cannot perform strict bitonal thresholding.
  • Page curvature is represented through transforms and shading rather than a full mesh warp.
  • PNG or JPEG export requires an optional external rasterizer or a browser screenshot tool.
  • Physical objects such as hands, paper clips and handwritten notes should be supplied by the application rather than procedurally invented.

License

MIT

Releases

Packages

Contributors

Languages