
Research
/Security News
Trivy Under Attack Again: Widespread GitHub Actions Tag Compromise Exposes CI/CD Secrets
Attackers compromised Trivy GitHub Actions by force-updating tags to deliver malware, exposing CI/CD secrets across affected pipelines.
Declarative PDF engine - define layout once, feed it JSON, get consistent PDFs
Define the layout once. Feed it JSON. The core is blind to both and does all the math.
The theme does not know what the content says. The JSON does not know how it looks. The core does not know it is rendering a newspaper, an invoice, or a certificate. Three blind components, one coherent output.
Source JSON + Theme = PDF
npm install h17-sspdf
Generating PDFs imperatively means tracking the cursor yourself. Every element you place shifts everything below it. Line wrapping, page breaks, font resets, all manual.
This engine inverts that. You describe what to render and how it looks. The cursor, the math, the page breaks happen automatically.
Every operation has a type and a label. The label maps to a style in the theme. The engine looks up the style, lays out the content, advances the cursor by an exact calculated amount, and moves to the next operation.
operation → label → theme style → layout → cursor advance → next operation
Page breaks happen automatically when content reaches the bottom margin. Style resets after every operation, nothing leaks.
const { renderDocument } = require('h17-sspdf');
renderDocument({
source: {
operations: [
{ type: 'text', label: 'doc.title', text: 'My Document' },
{ type: 'divider', label: 'doc.rule' },
{ type: 'text', label: 'doc.body', text: 'First paragraph.' }
]
},
theme: {
name: 'My Theme',
page: {
format: 'a4',
orientation: 'portrait',
unit: 'mm',
marginTopMm: 20,
marginBottomMm: 20,
marginLeftMm: 20,
marginRightMm: 20,
backgroundColor: [255, 255, 255],
defaultText: { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 10, color: [0,0,0], lineHeight: 1.4 },
defaultStroke: { color: [200,200,200], lineWidth: 0.3, lineCap: 'butt', lineJoin: 'miter' },
defaultFillColor: [255, 255, 255],
},
labels: {
'doc.title': { fontFamily: 'helvetica', fontStyle: 'bold', fontSize: 22, color: [0,0,0], lineHeight: 1.2, marginBottomMm: 4 },
'doc.rule': { color: [200,200,200], lineWidth: 0.3, marginBottomMm: 4 },
'doc.body': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 10, color: [40,40,40], lineHeight: 1.5 },
}
},
outputPath: 'output/doc.pdf'
});
The most complex built-in layout is a newspaper front page. It has a masthead, edition line, heavy rule, headline hierarchy, byline, multi-paragraph body, pull quote, stat scoreboard, and a footer that repeats on every page. Here is how it is built.
Declare it once. The engine stamps it on every page at the specified Y position.
{
"pageTemplates": {
"footer": [
{ "type": "divider", "label": "news.footer.rule", "x1Mm": 18, "x2Mm": 192 },
{
"type": "row",
"leftLabel": "news.footer.left",
"rightLabel": "news.footer.right",
"leftText": "The Meridian Times | Civic Desk",
"rightText": "Page {{page}}",
"xLeftMm": 18,
"xRightMm": 192
}
],
"footerHeightMm": 8,
"footerStartMm": 284
}
}
{{page}} is replaced with the current page number at render time.
The masthead, edition row, heavy rule, kicker, headline, deck, and byline are all inside a section. A section allows page breaks inside it but groups the content logically.
{
"type": "section",
"content": [
{ "type": "text", "label": "news.masthead", "text": "The Meridian Times", "xMm": 18, "maxWidthMm": 174 },
{
"type": "row",
"leftLabel": "news.edition.left",
"rightLabel": "news.edition.right",
"leftText": "Saturday, March 7, 2026",
"rightText": "Late City Edition",
"xLeftMm": 18,
"xRightMm": 192
},
{ "type": "divider", "label": "news.rule.heavy", "x1Mm": 18, "x2Mm": 192 },
{ "type": "text", "label": "news.kicker", "text": "Infrastructure & Society", "xMm": 18, "maxWidthMm": 174 },
{ "type": "text", "label": "news.headline", "text": "Local Governments Are Finally Rewriting How They Publish Public Records", "xMm": 18, "maxWidthMm": 174 },
{ "type": "text", "label": "news.deck", "text": "A quiet wave of procurement reform...", "xMm": 18, "maxWidthMm": 174 },
{
"type": "row",
"leftLabel": "news.byline",
"rightLabel": "news.timestamp",
"leftText": "By Marta Ruiz",
"rightText": "Updated 6:40 PM",
"xLeftMm": 18,
"xRightMm": 192
},
{ "type": "divider", "label": "news.rule.light", "x1Mm": 18, "x2Mm": 192 }
]
}
xMm and maxWidthMm override the page margins for this operation. This is how you position content independently of the theme margins.
Pass text as an array. Each string becomes a paragraph with the label's spacing applied between them.
{
"type": "text",
"label": "news.body",
"text": [
"First paragraph.",
"Second paragraph.",
"Third paragraph."
],
"xMm": 18,
"maxWidthMm": 174
}
keepWithNext: N tells the engine this operation must stay on the same page as the next N operations. Use it on section headings so they never strand at the bottom of a page.
{ "type": "text", "label": "news.section.title", "text": "Inside the shift", "keepWithNext": 3 }
A repeating pattern of row + text pairs. The row carries the label/value, the text below carries the annotation.
{
"type": "row",
"leftLabel": "news.stat.label",
"rightLabel": "news.stat.value",
"leftText": "Harbor City planning notices",
"rightText": "42% faster"
},
{
"type": "text",
"label": "news.stat.note",
"text": "Review time fell after zoning notices moved to a single contract."
}
{
"type": "quote",
"label": "news.pullquote",
"text": "When the format becomes a system instead of a template, agencies stop re-solving the same layout problem every week.",
"attribution": "— Elena Ward, public records modernization lead",
"xMm": 22,
"maxWidthMm": 166
}
xMm and maxWidthMm indent it from the body column, the indentation is in the source, not the theme.
Invisible in the rendered PDF, present in text extraction.
{
"type": "hiddenText",
"label": "news.hidden.tags",
"text": "public records procurement modernization searchable notices"
}
Each label the source uses must exist in the theme. These are the ones the newspaper source uses:
labels: {
'news.masthead': { fontFamily: 'custom', fontStyle: 'bold', fontSize: 36, color: [0,0,0], lineHeight: 1.1, marginBottomMm: 2 },
'news.edition.left': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 8, color: [80,80,80], lineHeight: 1 },
'news.edition.right':{ fontFamily: 'helvetica', fontStyle: 'italic', fontSize: 8, color: [80,80,80], lineHeight: 1, marginBottomMm: 1 },
'news.rule.heavy': { color: [0,0,0], lineWidth: 1.2, marginBottomMm: 2 },
'news.rule.light': { color: [160,160,160], lineWidth: 0.3, marginBottomMm: 3 },
'news.kicker': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 9, color: [100,100,100], lineHeight: 1, textTransform: 'uppercase', marginBottomMm: 1 },
'news.headline': { fontFamily: 'custom', fontStyle: 'bold', fontSize: 28, color: [0,0,0], lineHeight: 1.15, marginBottomMm: 3 },
'news.deck': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 11, color: [40,40,40], lineHeight: 1.4, marginBottomMm: 2 },
'news.byline': { fontFamily: 'helvetica', fontStyle: 'bold', fontSize: 8, color: [0,0,0], lineHeight: 1 },
'news.timestamp': { fontFamily: 'helvetica', fontStyle: 'italic', fontSize: 8, color: [80,80,80], lineHeight: 1, marginBottomMm: 2 },
'news.body': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 10, color: [20,20,20], lineHeight: 1.55, marginBottomMm: 3 },
'news.section.title':{ fontFamily: 'helvetica', fontStyle: 'bold', fontSize: 11, color: [0,0,0], lineHeight: 1.2, marginTopMm: 3, marginBottomMm: 1 },
'news.pullquote': { fontFamily: 'custom', fontStyle: 'italic', fontSize: 13, color: [30,30,30], lineHeight: 1.5,
leftBorder: { widthMm: 1.5, color: [0,0,0], paddingMm: 4 }, marginTopMm: 4, marginBottomMm: 4 },
'news.stat.label': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 9, color: [40,40,40], lineHeight: 1 },
'news.stat.value': { fontFamily: 'helvetica', fontStyle: 'bold', fontSize: 9, color: [0,0,0], lineHeight: 1 },
'news.stat.note': { fontFamily: 'helvetica', fontStyle: 'italic', fontSize: 8, color: [100,100,100], lineHeight: 1.3, marginBottomMm: 3 },
'news.brief.text': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 10, color: [20,20,20], lineHeight: 1.5 },
'news.brief.marker': { color: [0,0,0] },
'news.footer.rule': { color: [160,160,160], lineWidth: 0.3, marginBottomMm: 1 },
'news.footer.left': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 7, color: [120,120,120], lineHeight: 1 },
'news.footer.right': { fontFamily: 'helvetica', fontStyle: 'normal', fontSize: 7, color: [120,120,120], lineHeight: 1 },
'news.hidden.tags': { fontSize: 0.1, color: [255,255,255] },
}
| Type | Purpose | Key fields |
|---|---|---|
text | Wrapped text block | label, text (string or array of strings) |
row | Left/right pair on one line | leftLabel, rightLabel, leftText, rightText |
bullet | Marker + wrapped text | label, markerLabel, bullets (array) |
divider | Horizontal rule | label, x1Mm, x2Mm |
image | Embedded PNG/JPEG | src, width (percentage or mm), caption |
spacer | Vertical gap | mm, px, or label |
hiddenText | Invisible text | label, text |
quote | Blockquote with attribution | label, text, attribution |
block | Group children, optional background + border | children, keepTogether |
section | Logical group, allows breaks inside | content |
Any operation accepts xMm and maxWidthMm to override the theme margins for that operation only.
keepWithNext: N - keep this operation on the same page as the next N operationsblock with keepTogether: true - all children stay on the same page{
// Typography
fontFamily: 'helvetica', // or any registered custom font family
fontStyle: 'normal', // 'normal' | 'bold' | 'italic' | 'bolditalic'
fontSize: 10, // pt
color: [0, 0, 0], // RGB
lineHeight: 1.4, // multiplier
textTransform: 'uppercase', // 'uppercase' | 'lowercase' | undefined
// Spacing
marginTopMm: 0,
marginBottomMm: 0,
marginTopPx: 0,
marginBottomPx: 0,
paddingTopMm: 0,
paddingBottomMm: 0,
paddingTopPx: 0,
paddingBottomPx: 0,
// Dividers
lineWidth: 0.3,
// Container (block/section)
backgroundColor: [245, 245, 245],
borderColor: [200, 200, 200],
borderWidthMm: 0.3,
paddingMm: 4,
// Left border accent (quote, callout)
leftBorder: {
widthMm: 1.5,
color: [0, 0, 0],
paddingMm: 4,
},
}
20 Google Fonts ship with the package as base64 TTF. Each exports { Regular, Bold }.
Sans-serif: Inter, Roboto, Open Sans, Montserrat, Lato, Raleway, Nunito, Work Sans, IBM Plex Sans, PT Sans, Oswald
Serif: Merriweather, Lora, Playfair Display, Crimson Text, Libre Baskerville, Source Serif 4
Monospace: Fira Code, JetBrains Mono, Source Code Pro
const INTER = require('h17-sspdf/fonts/inter.js');
customFonts: [{
family: 'Inter',
faces: [
{ style: 'normal', fileName: 'Inter-Regular.ttf', data: INTER.Regular },
{ style: 'bold', fileName: 'Inter-Bold.ttf', data: INTER.Bold },
],
}],
List all fonts: npx h17-sspdf --fonts
20 built-in vector shapes rendered via jsPDF drawing primitives. No text encoding, no font dependencies.
Use as bullet markers by setting shape on a marker label:
// Theme
'bullet.arrow': { shape: 'arrow', shapeColor: [0, 128, 255], shapeSize: 0.8 }
// Source JSON (same bullet operation as always)
{ "type": "bullet", "label": "doc.body", "markerLabel": "bullet.arrow", "bullets": ["Point one"] }
Available: arrow, circle, square, diamond, triangle, dash, chevron, doubleColon, commentSlash, hashComment, bracketChevron, treeBranch, terminalPrompt, checkmark, cross, star, plus, minus, warning, infoCircle
List all shapes: npx h17-sspdf --shapes
Embed your own TTF as base64 and register in the theme:
customFonts: [
{
family: 'MyFont',
faces: [
{ style: 'normal', fileName: 'MyFont-Regular.ttf', data: '<base64>' },
{ style: 'bold', fileName: 'MyFont-Bold.ttf', data: '<base64>' },
],
},
],
Then use fontFamily: 'MyFont' in any label.
Renders any Chart.js configuration to a PNG and embeds it in the PDF.
The chart plugin requires the canvas npm package (native C++ addon). Chart.js and chartjs-node-canvas are vendored and ship with the engine.
npm install canvas
const { registerPlugin, plugins } = require('h17-sspdf');
registerPlugin('chart', plugins.chart);
{
"type": "chart",
"chartType": "bar",
"widthMm": 160,
"heightMm": 80,
"canvasWidth": 1600,
"canvasHeight": 800,
"data": {
"labels": ["Q1", "Q2", "Q3", "Q4"],
"datasets": [
{
"label": "Revenue",
"data": [120000, 145000, 138000, 172000],
"backgroundColor": "rgba(110, 158, 210, 0.80)"
}
]
},
"options": {
"scales": {
"y": { "beginAtZero": true }
}
}
}
data and options are passed directly to Chart.js, the plugin does not abstract the Chart.js API. canvasWidth/canvasHeight control render resolution (default 1600×800). widthMm/heightMm control the slot size in the PDF.
npx h17-sspdf -s source.json -t theme.js -o output.pdf
| Flag | Short | Description |
|---|---|---|
--source | -s | Path to source JSON (or pipe via stdin) |
--theme | -t | Path to theme .js file or built-in name |
--output | -o | Output PDF path |
--fonts | List built-in fonts | |
--shapes | List built-in vector shapes | |
--help | -h | Show help |
Claude Code skills for generating PDFs and themes are available in the skills/ directory of the GitHub repository:
skills/sspdf/ - Generate PDF documents from a task descriptionskills/sspdf-theme-generator/ - Generate theme files from brand specsrow cells, no multi-line column pairs{{page}} gives the current page number; {{pages}} (total page count) is not supported because keep-together rules make the final page count unpredictable until the last operation is laid outcanvas npm package (native C++ addon) for server-side rendering; everything else is zero native dependenciesHugo Palma, 2026
This project vendors the following MIT-licensed libraries:
Full license texts are in vendor/*/LICENSE.
FAQs
Declarative PDF engine - define layout once, feed it JSON, get consistent PDFs
The npm package h17-sspdf receives a total of 570 weekly downloads. As such, h17-sspdf popularity was classified as not popular.
We found that h17-sspdf demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
Attackers compromised Trivy GitHub Actions by force-updating tags to deliver malware, exposing CI/CD secrets across affected pipelines.

Security News
ENISA’s new package manager advisory outlines the dependency security practices companies will need to demonstrate as the EU’s Cyber Resilience Act begins enforcing software supply chain requirements.

Research
/Security News
We identified over 20 additional malicious extensions, along with over 20 related sleeper extensions, some of which have already been weaponized.