Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/badge-typography.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Badge Typography

PolicyChecks badges use proportional text measurement so labels with wide and narrow characters receive consistent horizontal spacing. Badge widths should come from the rendered text, not from character count and not from per-badge adjustments.

## Rendering Rule

The SVG renderer measures text with 11 px `Verdana` metrics and renders it with the existing Verdana-first font stack. It calculates each label and message segment independently:

```text
text width = ceil(sum of glyph advance widths)
segment width = max(44 px, text width + 10 px)
```

The additional 10 px provides a 5 px inset on each side. Very short strings may receive more space because every segment remains at least 44 px wide.

Each `<text>` element is centered within its segment and includes both `textLength` and `lengthAdjust="spacing"`. Those attributes preserve the intended text width when a browser uses a fallback font.

The implementation is in [`src/badges/svg.ts`](../src/badges/svg.ts).

## Adding Another Badge

New PolicyChecks badges inherit this typography automatically. Define the badge's `label` and optional `badgeMessage`, then add its definition to [`src/badges/registry.ts`](../src/badges/registry.ts). Do not pad labels with spaces, choose a fixed image width, or introduce per-badge padding.

To reproduce the same treatment in another SVG renderer:

1. Measure text using the same font family and size used in the SVG.
2. Add 10 px to the measured width and retain a sensible minimum segment width.
3. Center the text at the midpoint of its segment.
4. Set `textLength` to the measured width and use `lengthAdjust="spacing"`.

The current embedded metrics cover printable ASCII. A badge that displays other characters should add measurements for those glyphs or use a font-measurement library; otherwise, the renderer uses a conservative fallback width.

## Verification

Run the complete project check:

```bash
npm run check
```

For visual inspection, start the deterministic fixture server with `npm run dev:fixtures`, then open a badge such as:

```text
http://localhost:3000/github/example/project/sha-pinning-required.svg
```

Regression coverage in [`test/badges.test.ts`](../test/badges.test.ts) verifies the 5 px inset and confirms that equal-length strings containing different glyphs receive different widths.
42 changes: 35 additions & 7 deletions src/badges/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,30 @@ const colorHex = {
lightgrey: "#9f9f9f"
} as const;

const asciiStart = 32;
const fallbackCharacterWidth = 11;
const horizontalPadding = 10;
const minimumSegmentWidth = 44;

// Browser-measured advance widths for printable ASCII in 11 px Verdana. Keeping
// the text length explicit also makes the spacing stable when a fallback font is
// used to display the SVG.
const verdana11AsciiWidths = [
3.87, 4.33, 5.05, 9, 6.99, 11.84, 7.99, 2.95, 5, 5, 6.99, 9, 4, 5, 4, 5, 6.99, 6.99, 6.99, 6.99,
6.99, 6.99, 6.99, 6.99, 6.99, 6.99, 5, 5, 9, 9, 9, 6, 11, 7.52, 7.54, 7.68, 8.48, 6.96, 6.32,
8.53, 8.27, 4.63, 5, 7.62, 6.12, 9.27, 8.23, 8.66, 6.63, 8.66, 7.65, 7.52, 6.78, 8.05, 7.52,
10.88, 7.54, 6.77, 7.54, 5, 5, 5, 9, 6.99, 6.99, 6.61, 6.85, 5.73, 6.85, 6.55, 3.87, 6.85, 6.96,
3.02, 3.79, 6.51, 3.02, 10.7, 6.96, 6.68, 6.85, 6.85, 4.69, 5.73, 4.33, 6.96, 6.51, 9, 6.51, 6.51,
5.78, 6.98, 5, 6.98, 9
] as const;

export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult): string {
const label = definition.label;
const message = messageForResult(definition, result);
const labelWidth = textWidth(label);
const messageWidth = textWidth(message);
const labelTextWidth = textWidth(label);
const messageTextWidth = textWidth(message);
const labelWidth = segmentWidth(labelTextWidth);
const messageWidth = segmentWidth(messageTextWidth);
const width = labelWidth + messageWidth;
const messageX = labelWidth + messageWidth / 2;
const color = svgColor(colorForResult(definition, result));
Expand All @@ -31,16 +50,25 @@ export function renderBadgeSvg(definition: BadgeDefinition, result: BadgeResult)
<rect width="${width}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
<text x="${labelWidth / 2}" y="14">${escapeXml(label)}</text>
<text x="${messageX}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(message)}</text>
<text x="${messageX}" y="14">${escapeXml(message)}</text>
<text x="${labelWidth / 2}" y="15" textLength="${labelTextWidth}" lengthAdjust="spacing" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
<text x="${labelWidth / 2}" y="14" textLength="${labelTextWidth}" lengthAdjust="spacing">${escapeXml(label)}</text>
<text x="${messageX}" y="15" textLength="${messageTextWidth}" lengthAdjust="spacing" fill="#010101" fill-opacity=".3">${escapeXml(message)}</text>
<text x="${messageX}" y="14" textLength="${messageTextWidth}" lengthAdjust="spacing">${escapeXml(message)}</text>
</g>
</svg>`;
}

function textWidth(text: string): number {
return Math.max(44, Math.ceil(text.length * 7 + 10));
const measuredWidth = [...text].reduce((total, character) => {
const index = character.codePointAt(0)! - asciiStart;
return total + (verdana11AsciiWidths[index] ?? fallbackCharacterWidth);
}, 0);

return Math.ceil(measuredWidth);
}

function segmentWidth(measuredTextWidth: number): number {
return Math.max(minimumSegmentWidth, measuredTextWidth + horizontalPadding);
}

function svgColor(color: string): string {
Expand Down
30 changes: 30 additions & 0 deletions test/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,32 @@ describe("badge renderers", () => {
expect(renderBadgeSvg(communityHealthBadge, communityResult)).toContain("#6cc613");
});

it("uses proportional text metrics with consistent horizontal padding", () => {
const definition = {
...shaPinningRequiredBadge,
label: "secret push protection"
};
const svg = renderBadgeSvg(definition, result("enabled"));

expect(svg).toContain('width="188" height="20"');
expect(svg).toContain('width="134" height="20" fill="#555"');
expect(svg).toContain('x="67" y="14" textLength="124" lengthAdjust="spacing"');
expect(svg).toContain('x="161" y="14" textLength="44" lengthAdjust="spacing"');
});

it("allocates more width to wider glyphs in equal-length labels", () => {
const narrowSvg = renderBadgeSvg(
{ ...shaPinningRequiredBadge, label: "iiiiiiii" },
result("enabled")
);
const wideSvg = renderBadgeSvg(
{ ...shaPinningRequiredBadge, label: "WWWWWWWW" },
result("enabled")
);

expect(svgWidth(wideSvg)).toBeGreaterThan(svgWidth(narrowSvg));
});

it("renders unknown community health when no valid score is available", () => {
expect(toShieldsJson(communityHealthBadge, result("unknown"))).toMatchObject({
message: "unknown",
Expand Down Expand Up @@ -101,3 +127,7 @@ function result(result: BadgeResult["result"]): BadgeResult {
details: {}
};
}

function svgWidth(svg: string): number {
return Number(svg.match(/^<svg[^>]+ width="(\d+)"/)?.[1]);
}