Skip to content

drawSvg: the initial fill is not applied, and fill="none" does not override an inherited fill #165

Open

Description

@aheissenberger

What were you trying to do?

Draw an SVG logo exported from a design tool with page.drawSvg(). The logo's wordmark is one <path> without a fill attribute. It relies on SVG's initial fill, which is black.

How did you attempt to do it?

I called page.drawSvg(svg, { x, y, width, height }) with the unmodified SVG markup.

What actually happened?

The wordmark was missing from the PDF. Reducing it gives two cases where drawSvg paints differently from the SVG spec and from browsers:

  1. A shape with no fill on itself or an ancestor is not filled. A shape that relies on the initial fill is therefore not drawn at all.
  2. fill="none" does not override a fill inherited from an ancestor, so the shape is filled with the ancestor's color.

The related missing initial stroke-width of <rect>, <circle> and <ellipse> is reported in #164.

What did you expect to happen?

The initial fill should apply: black. fill="none" should mean "not filled", whatever an ancestor sets.

  1. A black square (operator f).
  2. A blue outline only (S).

How can we reproduce the issue?

import { PDFDocument } from '@cantoo/pdf-lib'

// Draws one SVG on a 100×100 page and returns the operator that paints it:
// f = fill, S = stroke, B = fill and stroke; null = nothing painted.
const paint = async (body, rootAttributes = '') => {
  const pdfDoc = await PDFDocument.create()
  const page = pdfDoc.addPage([100, 100])
  page.drawSvg(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" ${rootAttributes}>${body}</svg>`,
    { x: 0, y: 100, width: 100, height: 100 },
  )
  // A page nothing was drawn on has no content stream.
  const content = (page.node.normalizedEntries().Contents?.asArray() ?? [])
    .map((ref) => pdfDoc.context.lookup(ref).getContentsString())
    .join('')
  return content.match(/^([fSB])$/m)?.[1] ?? null
}

// 1. No fill set anywhere: the initial fill is black.            expected f, actual null
console.log('1', await paint('<rect width="10" height="10"/>'))
// 2. fill="none" overrides the fill inherited from the root.      expected S, actual B
console.log('2', await paint('<g fill="none" stroke="blue" stroke-width="1"><rect width="10" height="10"/></g>', 'fill="red"'))

Output with 2.11.1 (node repro.mjs):

1 null
2 B

Cause. In src/api/svg.ts, parseColor returns undefined for none (line 464). The attribute parser then falls back to the inherited value: fill: fillRaw?.rgb || inherited.fill (line 534), and the same for stroke (line 539). So "none" and "not set" can't be told apart, and nothing supplies the initial fill at the root.

Possible fix.

  • Start the root with the initial fill (black).
  • Represent none as an explicit value (e.g. null) that is inherited like a color but paints nothing, instead of undefined.

Version

2.11.1

What environment are you running pdf-lib in?

Node (v26); the same happens in the browser.

Checklist

  • My report includes a Short, Self Contained, Correct (Compilable) Example.
  • I have attached all PDFs, images, and other files needed to run my SSCCE. (None are needed.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions