A picture of one slide answers a narrower question than the one a theme picker is asked. What a person wants to know is what a deck will look like in this theme — all of it, at the size it will be shown, with the fonts substituted the way they will be. One rendered slide showed one layout, in content someone then read instead of looking at. So: a sample deck per theme. Every layout the renderer can draw — title, bullets with a sub-point, both two-column forms, table, callout, figure, full-slide figure, section divider, a custom slide with shapes, an arrow and a chart, and a references slide — with filler text throughout. Download it, open it, see the theme. The text is deliberately meaningless. Clinical content in a specimen invites you to read it, and then you are judging the teaching rather than the type; that is what the old croup slide got wrong. No engine. The previous preview needed a Gotenberg round trip and a pdftoppm to produce a PNG, cached on disk because of what it cost, and could fail in ways a missing picture cannot explain. python-pptx builds the file in ~330ms and PowerPoint draws it. The link is a plain anchor, so it is there whether or not anything on the server is well. The figure placeholder rides the same path a generated figure does — attachFigures downgrades a figure slide with no picture to bullets and an image slide to a section, so without it the sample would silently stop showing those two layouts. Verified: all five themes build, 11 slides, the chart is a real chart and the placeholder embeds as real media. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
87 lines
4.7 KiB
JavaScript
87 lines
4.7 KiB
JavaScript
// A deck's look is a theme, not a colour the model writes into a field. The
|
|
// slide vocabulary is structural — bullets, compare, table, callout — and none
|
|
// of it carries a colour, so this is the whole of a deck's styling.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
|
const deckSchema = require('../src/utils/deckSchema');
|
|
const catalogue = JSON.parse(read('assets/deck-themes.json')).themes;
|
|
|
|
test('every theme is complete, and every colour is a real colour', () => {
|
|
assert.ok(catalogue.length >= 4, 'a picker of one is not a choice');
|
|
for (const theme of catalogue) {
|
|
for (const key of ['id', 'name', 'accent', 'ink', 'muted', 'rule', 'paper', 'font']) {
|
|
assert.ok(theme[key], theme.id + ' is missing ' + key);
|
|
}
|
|
for (const key of ['accent', 'ink', 'muted', 'rule', 'paper']) {
|
|
assert.match(theme[key], /^[0-9A-Fa-f]{6}$/, theme.id + '.' + key + ' is not six hex digits');
|
|
}
|
|
}
|
|
const ids = catalogue.map(t => t.id);
|
|
assert.equal(ids.length, new Set(ids).size, 'theme ids must be unique');
|
|
});
|
|
|
|
test('the app and the renderer read one catalogue, so they cannot disagree', () => {
|
|
const py = read('scripts/render_pptx.py');
|
|
assert.match(py, /assets", "deck-themes\.json/);
|
|
const js = read('src/utils/deckSchema.js');
|
|
assert.match(js, /deck-themes\.json/);
|
|
assert.deepEqual(deckSchema.themes().map(t => t.id), catalogue.map(t => t.id));
|
|
});
|
|
|
|
test('an unknown theme is dropped rather than stored', () => {
|
|
// A stored deck must never claim a theme the renderer will silently ignore.
|
|
const slides = [{ type: 'bullets', heading: 'x', bullets: ['a'] }];
|
|
assert.equal(deckSchema.normalise({ theme: 'teaching-amber', slides }).theme, 'teaching-amber');
|
|
assert.equal(deckSchema.normalise({ theme: 'not-a-theme', slides }).theme, '');
|
|
assert.equal(deckSchema.normalise({ theme: '../../etc/passwd', slides }).theme, '');
|
|
assert.equal(deckSchema.normalise({ slides }).theme, '');
|
|
});
|
|
|
|
test('the renderer rebinds its palette instead of hardcoding one', () => {
|
|
// Every builder reads these names, which is what makes a theme a rebinding
|
|
// rather than a change to ten slide builders.
|
|
const py = read('scripts/render_pptx.py');
|
|
assert.match(py, /def apply_theme\(theme_id\)/);
|
|
assert.match(py, /global INK, MUTED, ACCENT, RULE, PAPER, FONT/);
|
|
assert.match(py, /apply_theme\(spec\.get\("theme"\)\)/);
|
|
// An unusable theme must not cost the deck its render.
|
|
assert.match(py, /a deck rendering in the wrong colours beats a deck not rendering/);
|
|
});
|
|
|
|
test('re-skinning is a column write, never a regeneration', () => {
|
|
const route = read('src/routes/myResources.js');
|
|
const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'"));
|
|
assert.match(handler.slice(0, 1400), /UPDATE user_resources SET deck = \?::jsonb/);
|
|
assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide');
|
|
assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner');
|
|
assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme');
|
|
});
|
|
|
|
test('the theme is shown by a deck the renderer built, not a mocked-up swatch', () => {
|
|
// A hand-drawn swatch drifts the moment a palette or a layout changes. This
|
|
// used to be a server-rendered PNG of one slide; it is now a downloadable
|
|
// sample deck of every layout — same principle, no Gotenberg.
|
|
// See test/deck-theme-sample.test.js.
|
|
const route = read('src/routes/myResources.js');
|
|
const sample = route.slice(route.indexOf("router.get('/my-resources/theme-sample/:id'"));
|
|
assert.match(sample.slice(0, 1800), /documentExport\.renderDeck\(deck, \[deckSample\.FIGURE\]/);
|
|
assert.match(sample.slice(0, 1800), /deckSchema\.themeId\(req\.params\.id\)/, 'the id is validated, not used as a path');
|
|
// Scoped to this route: the deck reviewer legitimately rasterises the deck
|
|
// it is about to judge. Showing a theme does not.
|
|
const body = sample.slice(0, sample.indexOf('\n});'));
|
|
assert.doesNotMatch(body, /slideImages|GOTENBERG|pdftoppm/, 'no rasterising to show a theme');
|
|
assert.doesNotMatch(route, /THEME_PREVIEW_DIR|theme-preview/, 'the cached-PNG preview is gone');
|
|
});
|
|
|
|
test('the library offers a theme only where there is a deck to re-skin', () => {
|
|
const ui = read('public/js/myResources.js');
|
|
assert.match(ui, /row\.kind !== 'article' && row\.has_deck !== false/);
|
|
assert.match(ui, /data-theme|dataset\.theme/);
|
|
// A failed change puts the control back rather than showing a theme the
|
|
// resource does not have.
|
|
assert.match(ui, /if \(previous\) select\.value = previous;/);
|
|
});
|