Skip to content

Change it, export it

Exercise modify-embed-export-annex.pdf · PDF

You cooked the report, the customer tasted it, and now the plate is back at the pass with a note: nice, but could I also have...? Has a teacher or a client ever accepted your first version without asking for a change?

Reports are among the most-changed screens of any app, because the people who read them keep having new questions. A good cook does not start again when a dish comes back. They know which pan to touch. Today you learn to do the same, then you let the customer take the report away, and finally you prepare the exam of this unit.

What you will learn

  • Read a change request, decide which layer of the report it touches, and change it in one place
  • Verify a change with an acceptance test: numbers you know before you touch the code
  • Say what makes a report embedded
  • Export a report three ways: print, CSV and PDF
  • Know the format, rules and marking of the Unit 6 exam
  • The trap: letting the screen and the PDF filter on their own

Which pan do you touch?

A change request is a written request to change something that already works. Its acceptance test says how you will know it is done: for a report, the numbers the screen must show.

Every request lands in one layer:

The request is about... Change this File
A column that is not in the data The data source + toNote notes.csv, report.js
Which rows appear filterNotes + a control report.js, the page
A new value from other columns toNote (calculated value) report.js
A new count, sum or average totals report.js
How the table looks Markup and CSS The page
What a chart shows The option object The page
The paper version The report file notes-report.typ
What every PDF shares The template template.typ

This is why you kept pure functions in report.js, the markup in the page, and the form in the template: each ticket finds one small place to land.

Careful: it compiles, so it is done?

No. It is done when the acceptance numbers appear. Compute them before you code, and you know what you are aiming at.

Three change requests

Start from your /report route from the charts lesson. For each ticket, first write down where the change goes, with no code. Then code it. Done when the screen shows the numbers.

The customer says Done when
CR1 "Show which notes are pinned, and how many per folder and in total." No filter: Personal 1 · School 2 · Shopping 2 · Work 4 · total 9 pinned
CR2 "I want the notes of November only: I can choose the first day but not the last." From 01/11/2026 until 30/11/2026: 9 notes · 1189 words · 10 min, and no School group
CR3 "School always wins. Show the average words per note, highest first." Bars from left to right: School 435 · Work 259 · Personal 182 · Shopping 41. Title "Average words per note"
CR1: a new count, after you have tried

The change goes in totals, plus the markup. You do not need to write the count twice, once for the group and once for the report. groupByFolder already calls totals, so one new line gives the count to every group and to the report footer:

export function totals(rows) {
  const words = rows.reduce((sum, n) => sum + n.words, 0);
  return {
    count: rows.length,
    words,
    minutes: rows.reduce((sum, n) => sum + n.minutes, 0),
    pinned: rows.filter((n) => n.pinned).length,
    average: rows.length ? Math.round(words / rows.length) : 0
  };
}

In the page: a header cell <th>Pinned</th>, the group header with colspan="5", and one more cell in the detail row, the group footer and the report footer:

<td>{note.pinned ? 'Yes' : ''}</td>      <!-- detail -->
<td>{group.pinned} pinned</td>           <!-- group footer -->
<td>{total.pinned} pinned</td>           <!-- report footer -->
CR2: a new parameter, after you have tried

The change goes in filterNotes, plus a control:

export function filterNotes(notes, { folder = 'All', from = '', until = '', pinnedOnly = false } = {}) {
  return notes.filter(
    (n) =>
      (folder === 'All' || n.folder === folder) &&
      (from === '' || n.created >= from) &&
      (until === '' || n.created <= until) &&
      (!pinnedOnly || n.pinned)
  );
}

In the page, add let until = $state('');, pass until in the object given to filterNotes, and add the control:

<label>
  Created until
  <input type="date" bind:value={until} />
</label>

Then add until = ''; to reset. The filter that cannot be reset is the classic bug of this ticket.

CR3: the chart option only, after you have tried

Not one line of report.js changes. The average is already in each group:

let wordsOption = $derived({
  title: { text: 'Average words per note' },
  color: ['#1f5fd1'],
  tooltip: {},
  dataset: {
    source: groups
      .map((g) => ({ folder: g.folder, average: g.average }))
      .sort((a, b) => b.average - a.average)
  },
  xAxis: { type: 'category' },
  yAxis: { type: 'value', name: 'words per note' },
  series: [{ type: 'bar', encode: { x: 'folder', y: 'average' }, label: { show: true, position: 'top' } }]
});

Update the label of the <Chart> too, so that its accessible name still tells the truth.

Embedded, not attached

An embedded report lives inside the application: the user reaches it from the app's navigation, it reads the app's own data, and it looks like the rest of the app.

The opposite is the attached report: export everything to a spreadsheet, and build the report somewhere else.

Embedded Attached (spreadsheet, external tool)
Reached from A link in the app Another program
Data Live: the same the app shows A copy, already old
Filters The app's controls Whatever the other tool offers
Look The app's own components and styles Another look
Who can use it Every user of the app Whoever knows the other tool

"Embedded" does not mean "in the same repository". It means the user never leaves the app. Check four things in your own project, and fix whatever is missing:

  1. A link to the report in the navigation, not a URL to know by heart: <a href="/report">Report</a> in +layout.svelte (see Routing and navigation).
  2. A title in the browser tab: <svelte:head><title>Notes report · NoteBox</title></svelte:head>.
  3. The app's data: the report reads the same source as the rest of the app, not a private copy.
  4. The app's look: it uses your components and styles, not the browser's default grey.

Take-away 1: print

It takes one line of JavaScript:

<button type="button" onclick={() => window.print()}>Print</button>

Press it, and the preview shows the app bar, the filters and the buttons. None of them work on paper. The real work is in the CSS. Add to the page's <style>, with the filters wrapped in class="filters" and the buttons in class="export":

@media print {
  .filters, .export { display: none; }
}

Do the same in +layout.svelte for the app bar.

Which bands does the browser add by itself when printing? A page header and a page footer (date, URL, page number), if the user leaves them on. The browser decides, not you. That is the limit of printing a web page, and the reason for the third take-away.

Take-away 2: CSV

Now the rows on the screen become a file the user can open in a spreadsheet. Everything happens in the browser: build the text, wrap it in a Blob (a chunk of data the browser treats like a file), and click a temporary link.

function exportCsv() {
  const lines = [
    'id,title,folder,created,words,minutes',
    ...rows.map((n) =>
      [n.id, `"${n.title.replaceAll('"', '""')}"`, n.folder, n.created, n.words, n.minutes].join(',')
    )
  ];
  const url = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/csv' }));
  const a = document.createElement('a');
  a.href = url;
  a.download = 'notes-report.csv';
  a.click();
  URL.revokeObjectURL(url);
}

Three details matter:

  • It uses rows, not data.notes: the export must contain what the user is looking at, with the same filter.
  • It puts quotes around the title, because a title may contain a comma, and RFC 4180 says that quotes inside a quoted value are doubled.
  • minutes is not in the original CSV. The file that leaves is richer than the file that came in.

Take-away 3: PDF, the app runs Typst

Here is the plan:

flowchart LR
    B["<b>Export PDF</b><br>the user clicks"] --> P["<b>The page</b><br>sends its rows"]
    P --> S["<b>Server endpoint</b><br>runs typst compile"]
    S --> D["<b>The PDF</b><br>comes back and downloads"]

Why does it need a server? A web page cannot start programs on your computer. Code that runs on the server can.

Modify the report file

Your Typst file reads the CSV and filters by itself, with one parameter. The screen has three filters. Two filter codes drift apart, and one day they disagree and nobody knows which is right. So this is a fourth change request, on the Typst code: the PDF must contain exactly the rows of the screen.

In reports/notes-report.typ, the data and the parameter change:

// 1 · Data: one dictionary per CSV row. Every value arrives as text.
//     If the app sends its rows (--input rows=[...]), use exactly those.
#let from-app = "rows" in sys.inputs
#let notes = if from-app { json(bytes(sys.inputs.rows)) } else {
  csv("../static/data/notes.csv", row-type: dictionary)
}

// 2 · Parameter: typst compile --input folder=School ...
#let folder = if from-app { json(bytes(sys.inputs.filter)).folder } else {
  sys.inputs.at("folder", default: "All")
}

Then change one cell. Rows from the app carry words as a number, so the detail row writes str(n.words) instead of n.words. Both int(n.words) and str(n.words) work on text and on numbers, so the file still compiles from the command line as before.

The endpoint

New file src/routes/report/pdf/+server.js:

import { error } from '@sveltejs/kit';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

// POST /report/pdf  { rows: [...], filter: {...} }  →  application/pdf
export async function POST({ request }) {
  const { rows, filter } = await request.json();
  try {
    const { stdout } = await run(
      'typst',
      [
        'compile',
        '--input', `rows=${JSON.stringify(rows)}`,
        '--input', `filter=${JSON.stringify(filter)}`,
        'reports/notes-report.typ',
        '-' // write the PDF to stdout
      ],
      { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }
    );
    return new Response(stdout, {
      headers: {
        'content-type': 'application/pdf',
        'content-disposition': 'attachment; filename="notes-report.pdf"'
      }
    });
  } catch (e) {
    console.error(e.stderr?.toString() ?? e);
    error(500, 'Typst could not compile the report');
  }
}

execFile passes each argument separately, and no shell reads them, so a note titled "; rm -rf ~ is only a title. Never build a command line by gluing text together: it is the same rule as with SQL.

The button

let exporting = $state(false);
let exportError = $state('');

async function exportPdf() {
  exporting = true;
  exportError = '';
  try {
    const response = await fetch('/report/pdf', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ rows, filter: { folder, from, pinnedOnly } })
    });
    if (!response.ok) throw new Error(await response.text());
    const url = URL.createObjectURL(await response.blob());
    const a = document.createElement('a');
    a.href = url;
    a.download = 'notes-report.pdf';
    a.click();
    URL.revokeObjectURL(url);
  } catch {
    exportError = 'The PDF could not be created. Check that Typst is installed and try again.';
  } finally {
    exporting = false;
  }
}
<button type="button" onclick={exportPdf} disabled={exporting}>
  {exporting ? 'Creating PDF…' : 'Export PDF'}
</button>
{#if exportError}<p class="error" role="alert">{exportError}</p>{/if}

The disabled button with "Creating PDF…" is visibility of system status, Nielsen's first heuristic, in your own code.

Careful: one kitchen

The screen decides the rows, and every export receives those rows. If the PDF filtered again by itself, the two codes would drift apart.

Two limits of this design

Size. The rows travel to Typst as a command-line argument, and Linux limits one argument to about 128 kB. That is fine for hundreds of rows, not for tens of thousands. A big report would write a temporary JSON file and pass its path.

A server. The endpoint works with npm run dev and with a Node deployment, on a machine where typst is installed. An app packaged with Tauri has no Node server, so the export must be solved differently there.

Going further: the chart inside the PDF

One option, two outputs. The page also sends option: wordsOption in the body, and the endpoint renders the same option with ECharts on the server, as an SVG text, between reading the request and running Typst:

import * as echarts from 'echarts';

  const { rows, filter, option } = await request.json();

  // Same ECharts option as on screen, rendered on the server as an SVG string
  const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 640, height: 300 });
  chart.setOption({ ...option, animation: false });
  const svg = chart.renderToSVGString();
  chart.dispose();

  // ...and one more argument for Typst:
        '--input', `chart=${svg}`,

In notes-report.typ, above the table:

// Chart: the SVG the app rendered with ECharts, if it sent one
#if "chart" in sys.inputs {
  image(bytes(sys.inputs.chart), format: "svg", width: 100%, alt: "Bar chart: words per folder")
}

Three exports compared

Print CSV PDF with Typst
Made by The browser JavaScript in the browser Typst, on the server
Layout The browser decides page breaks, header and footer None: it is data Yours: template, page header, page footer, repeated column header
Good for A quick paper copy More analysis in a spreadsheet Sending, archiving, an official document
Needs @media print A Blob Typst installed where the app runs

Prepare the exam

The Unit 6 exam is on Monday 1 February 2027, 11:15–13:05.

Part What it is
Warm-up A Kahoot, 5 minutes, no mark
Part A · theory 20 minutes, on paper, 5 questions, 2.5 points. Nothing on the desk
Part B · practical 80 minutes, on the PC, 7.5 points. A NoteBox starter project, a CSV you have not seen, and a Figma design. You build the embedded report and its Typst PDF
Allowed in Part B Your own notes and projects of this unit, and the official docs: svelte.dev, echarts.apache.org, typst.app/docs
Not allowed AI of any kind, chats, messaging, other people's work. Copying means a 0 for everyone involved
Hand-in A .zip of the project without node_modules, plus the PDF, in Classroom before 13:05

Part B has seven tasks:

# Task RA5 Points
1 Structure: report header, column header, groups with header and footer, report footer, as in the design a 1.0
2 Filters: a folder selector fed from the JSON, a checkbox, and the empty state b, c 1.0
3 Two calculated columns, and counts and totals per group and for the report d 1.5
4 One ECharts chart that reacts to the filters e 1.25
5 The Typst PDF: table with groups, calculated columns, subtotals, grand total f 1.25
6 A change request on the given template code g 0.75
7 Embedded and exported: a link in the navigation, a print button and a clean print view h 0.75

A drill on paper: percentages

Percentages have the same trap as averages. Take these invented rows, and on paper, for 5 minutes, work out: for each row, the pending tasks and the progress in %; and for each folder and for the total, the tasks, done, pending and progress.

Folder List Tasks Done
A Trip 10 5
A Party 2 2
B Exams 8 4
Check your drill
Tasks Done Pending Progress
Trip 10 5 5 50%
Party 2 2 0 100%
Folder A 12 7 5 58%
Exams 8 4 4 50%
Folder B 8 4 4 50%
Total 20 11 9 55%

Did you write 75% for folder A, or 67% for the total? 75 is the average of 50 and 100, and 67 is the average of the three rows. Both are wrong, for the same reason as the average trap: the tiny list of 2 tasks gets the same vote as the big one of 10. Total progress = total done ÷ total tasks: 7 ÷ 12 = 58%, and 11 ÷ 20 = 55%.

Your checklist for the weekend

  • Your project builds (npm run build), and typst --version answers on the PC you will use.
  • You can find in your own project, in under a minute: toNote, filterNotes, totals, groupByFolder, Chart.svelte and the Typst group loop.
  • You can log in to Figma and to Classroom.
  • Exam technique: before you code, add up one group by hand. If your screen says something else, you know where to look.

Key takeaways

  • A change request lands in one layer. Most touch one function or one object, so find the layer first.
  • A change is done when its acceptance numbers appear, not when it compiles.
  • An embedded report keeps the user inside the app: its navigation, its data, its look.
  • Three take-aways: print (@media print), CSV (a Blob), and PDF (a server runs Typst).
  • The screen decides the rows, and every export receives those rows.
  • Total progress is total done ÷ total tasks, never an average of percentages.

Quick check

1. A customer asks for the chart to show the shortest notes first instead of the longest. Where does the change go?
  • In the chart option, which sorts the data it gives to the chart
  • In toNote, which converts the rows that come from the CSV file
  • In the Typst template, which sets the page, the header and the footer
  • In the data source, which has to be written again in a new order

What a chart shows is decided by its option. A request that touches a chart usually changes nothing in report.js, and finding that one layer is the whole skill.

2. You add a new count of notes per folder and for the whole report. Why does one new line in totals give you both?
  • Because groupByFolder calls totals for each group, and the page calls it for all the rows
  • Because Svelte copies each new key automatically from one component to all the others
  • Because totals is $state, so a new key spreads through the page without any code
  • Because the CSV file already stores that count in a column for every folder and row

One function serves the group footer and the report footer. Writing the count twice would create two pieces of code that can disagree.

3. What is an acceptance test for a report change?
  • Numbers known before coding, which the screen must show when the change is done
  • A compilation with no errors, which proves that the report now does what was asked
  • A message from the customer, sent after the change is live, to say that they like it
  • A second copy of the report, kept on one side in case the change has to be undone

Compiling only proves the code runs. Computing the expected numbers first gives you a target, and it tells you where to look if the screen disagrees.

4. A company exports its data to a spreadsheet every Monday and builds its charts there. Why is that not an embedded report?
  • Users leave the app, and they work on a copy of the data that is already old
  • A spreadsheet cannot draw charts with the data it gets from another program
  • The code of the spreadsheet is not stored in the same repository as the app
  • The data is exported as CSV, and an embedded report can only ever read JSON

An embedded report is reached from the app, reads its live data and looks like it. Being in the same repository has nothing to do with it: what matters is that the user never leaves the app.

5. Why does exportCsv build the file from rows and not from data.notes?
  • The export must contain what the user sees, with the filters applied
  • The variable data.notes is only available on the server, not in the page
  • A Blob can only be created from values that have been declared $derived
  • The original notes have no words column, so they cannot be exported at all

rows holds the filtered rows, with their calculated values. Exporting something different from the screen would surprise the user.

6. Why does the Export PDF button need a server endpoint?
  • Because the browser cannot start programs such as Typst on your computer
  • Because a PDF file is too large to be downloaded from a page in the browser
  • Because a POST request is the only way of building a table with a total
  • Because Typst can only read its data from a database that is on the server

A web page runs in a sandbox and cannot launch programs. Server code can, with execFile. Print and CSV, by contrast, happen entirely in the browser.

7. Why does the endpoint use execFile with a list of arguments, and not a command line glued together as text?
  • No shell reads the arguments, so a strange note title cannot become a command
  • A list of arguments makes the PDF smaller, because the text is compressed first
  • Node.js refuses to run any program unless its arguments are written as a list
  • A list lets Typst compile several reports at once, each in a separate process

Each argument travels separately, so a title like "; rm -rf ~ stays a title. It is the same rule as the placeholder in SQL: never build a command by gluing text together.

8. A gym's report shows two classes: 30 people of whom 15 came back, and 10 people of whom all 10 came back. What should the total return rate be?
  • 62.5%, because 25 of the 40 people came back
  • 75%, because that is halfway between 50% and 100%
  • 150%, because the footer adds up the two percentages
  • 100%, because the footer shows the highest of the rates

15 + 10 = 25 returned out of 30 + 10 = 40, so 25 ÷ 40 = 62.5%. The 75% is an average of percentages, which gives the small class the same vote as the big one. Always go back to the totals.

Your report is finished: structured, filtered, charted, on paper and inside the app. In the next unit you document the app you have built: Help systems and docs as code.