puppeteer: Headless PDF printing inconsistent page width and height

I’m currently encountering some potential rounding issues with the PDF page size where I have to use an additional few 10ths of a millimetre on the width and height for the pages, the @pages declaration in my CSS, and also the CSS height of my sections to render (mostly) correctly.

Even after adding the extra mm to cover the issue, the width then increases by about 1-2mm on either side of the page, only when Puppeteer is used to generate the PDF, but not when printed as PDF directly from my local Chrome (65.0.3325.181).

The removal of the additional mm breaks the layout in both local Chrome and puppeteer rendered PDFs.

Possibly similar issue to #666.

Tell us about your environment:

What steps will reproduce the problem?

const puppeteer = require('puppeteer');
const fs = require('fs');

const DELTA = 0.27; // closest additional mm to not break layout

(async () => {
  let browser;
  try {
    browser = await puppeteer.launch({
      headless: true,
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-gpu',
        '--hide-scrollbars',
        '--disable-web-security',
      ],
    });
    const page = await browser.newPage();
    await page.goto(
      `data:text/html,${fs.readFileSync('index.html').toString('utf8')}`,
    );
    await page.pdf({
      path: './index.pdf',
      displayHeaderFooter: false,
      printBackground: true,
      pageRanges: '1-2',
      height: 139 + DELTA + 3 + 3 + 'mm', // Additional page margins of 3mm
      width: 550 + DELTA + 3 + 3 + 'mm', // Additional page margins of 3mm
      margin: {
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
      },
    });
    await browser.close();
  } catch (err) {
    if (browser) {
      await browser.close();
    }
    throw err;
  }
})();
  1. node index.js.
  2. Open PDF.
  3. Open HTML file directly in Chrome browser.
  4. Print as PDF.
  5. Compare (may have to zoom in to see).

What is the expected result?

PDF should not have any margin/whitespace on the page edges.

What happens instead?

There is a horizontal page gap on either side of each page of about 1mm. Should not need to use any additional mm adjustment - raw sizes should render as expected.

About this issue

  • Original URL
  • State: open
  • Created 6 years ago
  • Reactions: 87
  • Comments: 41 (1 by maintainers)

Most upvoted comments

Approaching this from a different angle… how would one produce a 73pt × 73pt PDF (to pick an arbitrary size that doesn’t appear possible)?

There seem to be many issues raised that are related to the same rounding problem. This one is currently the highest voted. Many have suggested workarounds that don’t seem broadly applicable, or involved increasing or decreasing the page size to something that did round.

Here’s a reproduction using @page sizing:

const puppeteer = require('puppeteer');

const pt = process.argv[2];
const widthPt = pt;
const heightPt = pt;

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.emulateMedia('screen');
  await page.setContent(`<html>
  <head>
    <style>
      @page {
        size: ${widthPt}pt ${heightPt}pt;
      }
      body {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <div style="width: ${widthPt}pt; height: ${heightPt}pt; background: #F00;">Page 1</div>
    <div style="width: ${widthPt}pt; height: ${heightPt}pt; background: #00F;">Page 2</div>
  </body>
</html>
`);
  await page.pdf({
    path: `${widthPt}ptx${heightPt}pt.pdf`,
    preferCSSPageSize: true,
    printBackground: true,
  });
  await browser.close();
})();

then one can inspect the output size like:

$ PT=73 && node ./index.js ${PT} && strings ./${PT}ptx${PT}pt.pdf | grep MediaBox
/MediaBox [0 0 72.959999 72.959999]
/MediaBox [0 0 72.959999 72.959999]
/MediaBox [0 0 72.959999 72.959999]

(in this example, some of the blue box extends onto page 3)

I tried various roundings to try to catch it out, but couldn’t work out what it’s doing.

pt input equiv. px (@96/in) equiv. in equiv. mm PDF pt output
73.499999 97.99999867 1.020833319 25.92916631 73.919998
73.49999 97.99998667 1.020833194 25.92916314 72.959999
73 97.33333333 1.013888889 25.75277778 72.959999
72.75 97 1.010416667 25.66458333 72.959999
72.56692913 96.75590551 1.007874016 25.6 72.959999
72.500001 96.666668 1.006944458 25.57638924 72.959999
72.5000001 96.6666668 1.006944446 25.57638892 72
72.5 96.66666667 1.006944444 25.57638889 72
72.2834645669291 96.37795276 1.003937008 25.5 72
72 96 1 25.4 72

Commenting to prevent stalebot from closing: Still an issue with 77 upvotes

@aslushnikov, just done so.

I appreciate that there’s a lot to do. Though this issue does make it impossible to output a PDF with more than one non-white page without a line at the bottom or right hand sides of the page, which for an app I’m building is an on going problem.

The use case of the PDF output is almost limitless. There is literally no other good, modern and maintained HTML5 to PDF solution out there.

I’ve just come across this issue.

Could implementing that option be of any help to this issue?

We’re marking this issue as unconfirmed because it has not had recent activity and we weren’t able to confirm it yet. It will be closed if no further activity occurs within the next 30 days.

So I dug in this issue a little bit more and discovered two problems @aslushnikov .

The most important is visible on this picture: image As you can see there is a black element which is overlapping from the first on second page. I would expect a continuous element on both pages. But as you can see there is a white stripe at the buttom of the page.

The second problem is the conversions of units. Imagine we give width: "100mm" as a property to the .pdf(). The following conversion path is now happening:

100 mm -> 378 px -> 3.9375inch -> And in the pdf source its 282.95999 pt

The second calculation inside pupeteer/chromium from inch to pt seems also to be wrong. The correct conversion from 3.9375inch to pt is 283.5 pt.

If we go one step back and use our fav search website to convert mm into inch we see that the calculation inside puppeteer is wrong. The correct value would be 3,93701 inch. If we convert 3.93701 inch to pt we get 283.46472pt.

This is a difference of 0.50473 pt or 0.178216%.

But as @tclift mentioned this could be different for different dimentions…

I would love to fix this bug but i dont know where to start. If I could get a direction I would dig more into it…

Update So I found that this buggs exist in chrome itself… I found this old question on stackoverflow: https://stackoverflow.com/questions/36561518/how-to-remove-margin-at-bottom-of-print-pdf-in-chrome

If I use my chrome browser to export my page to pdf I have this white stroke aswell !

Maybe I get something wrong. But I think this might be a bug in chrome…

🥳 five years and going!

activity

This issue still exists !

Even with @page setting there is still a white stripe at the bottom.

image

In addition to @tclift’s comment.

It seems that a width and height that are multiples of 8 pixels, makes the generated pdfs always correct.

For the A4 format : 8.27in x 11.7in = 595.44pt x 842,4pt The generated pdf give a /MediaBox of [0 0 594.95996 841.91998] (which creates visual imperfections).

With the nearest multiple of 8 pixels : 8.27in = 793.92px -> 792px = 594pt 11.7in = 1123.2px -> 1120px = 840pt By providing these values to puppeteer (792px x 1120px), the generated pdf will have a /MediaBox of [0 0 594 840] which is correct.

Some observations :

  • a multiple of 8px always give a round number in pt (8px = 6pt, 16px = 12pt, etc)
  • a rounded value in pt which is not a multiple of 8px won’t work (12px = 9pt but give a /MediaBox of [0 0 9.1199999 9.1199999], etc)
  • for each multiples of 8px, if you add 1, the /MediaBox will be the same as n-1 (eg 9px give a /MediaBox of [0 0 6 6], etc)

Conversion rules (at 96 DPI) :

  • in/px 𝑥in = (𝑥 * 96)px 𝑥px = (𝑥 / 96)in
  • in/pt 𝑥in = (𝑥 * 72)pt 𝑥pt = (𝑥 / 72)in
  • pt/px 𝑥pt = (𝑥 / 72 * 96)px 𝑥px = (𝑥 / 96 * 72)pt

I was having an issue printing a single image with 0 overflow/bleed issues, but got it all worked out. Here’s the css that i ended up with:

       @page {
          margin: 0;
          padding: 0;
        }

        html, body {
          width: ${width};
          height: ${height};
          margin: 0;
          padding: 0;
          overflow: hidden;
          font-size: 0;
        }
`<body><img src="${image.src}" style="width: 100%; height: 100%" /></body>`

font-size: 0; was necessary in this case to prevent the body from containing a line-height that added an extra ~1-2px at the end of the page.

@michaelschufi Yes. My example code will return a PDF with perfect A4 sizes. Although we do not need to adjust sizes for office printers. Chrome’s default settings are enough for it.

I had same task as the author of this issue. I had to add 3mm on each side and the accuracy of the final PDF file should be perfect. Because these PDFs will be printed on a professional book publishing printer.

Okay, well I’ve got a brute force attack that seems to fix the problem, I’d recommend finding a balance for how long you want to wait for the thing to get it correct, but I resorted to this due to this being some kind of bad float rounding bug.

Could be optimized by doing a binary search for the magic float. Sigh… I can’t believe I’ve even resorted to something like this 😂

The idea is to get what the correct height for the page you’re printing should be. And then to purposefully oversize it. Then you generate the pdf, open it in pdfJs, see how many pages there are… If there are too many pages, then keep decrementing the height until you have only 1 page.

The finer the decrement, the less likely you’re going to get a roundoff issue.

NOTE there is scaling to account for the 72 DPI of my device vs pupeteer’s 96 DPI hard coded DPI setting. If you’re coordinates aren’t lining up to what you’d expect to see, this is the cause.

https://github.com/puppeteer/puppeteer/issues/3357#issuecomment-581332452

NOTE, this is really only for if you’re trying to make 1 giant single page pdf, but the technique can still probably be applied by making single pdfs of each container and joining them together with some other software… You could also get away with setting up your app to only do one page at a time and then programmatically rendering each page.

If you’re trying to do this on someone else’s page, inject a script that handles that programmatic stuff for you by saving off dom nodes and then cycles through them with more page.evaluates.

Note, I am manually increasing width by + 1, but the same thing that’s being done to height can also be done to width. Just run a second loop after the first loop that acts on width instead of height

I know this is sloppy, but I don’t have time to really break it down. Best of luck!

The “magic” 💩 is here:

const pdfjsLib = require("pdfjs-dist/legacy/build/pdf");
let response = await page.goto(
`http://localhost:3000/generate?emailIndex=${index}`,
{
  waitUntil: ["load", "networkidle2"]
}
);

if (response === null) {
// bug with puppeteer: returning null, this is a workaround
response = await page.waitForResponse(() => true);
}

const { height, width } = await page.evaluate(() => ({
  // eslint-disable-next-line no-undef
  height: document.documentElement.scrollHeight,
  // eslint-disable-next-line no-undef
  width: document.querySelector("div.MuiBox-root.css-ap3adg").clientWidth
}));

let heightIncrement = -5;

while (true) {
const adjustedHeight = height / 0.75 + heightIncrement;

const pdfBuffer = await page.pdf({
  height: adjustedHeight,
  width: width / 0.75 + 1,
  scale: 1 / 0.75,
  printBackground: true
});

const pdfDoc = await pdfjsLib.getDocument({
  data: new Uint8Array(pdfBuffer)
}).promise;

// Get the number of pages

if (pdfDoc.numPages === 1) {
  await fs.writeFile(filename, pdfBuffer);
  break;
}

console.error("We have a problem: ", filename, adjustedHeight);

heightIncrement += 0.05;
}

Mostly full code below, you should be able to piece out what you need for your use.

const pdfjsLib = require("pdfjs-dist/legacy/build/pdf");
const page = await browser.newPage();
const username = usernames[email];

const filename = `${__dirname}/analytics-report-${username}.pdf`;

let response = await page.goto(
  `http://localhost:3000/generate?emailIndex=${index}`,
  {
    waitUntil: ["load", "networkidle2"]
  }
);

if (response === null) {
  // bug with puppeteer: returning null, this is a workaround
  response = await page.waitForResponse(() => true);
}
const { height, width } = await page.evaluate(() => ({
  // eslint-disable-next-line no-undef
  height: document.documentElement.scrollHeight,
  // eslint-disable-next-line no-undef
  width: document.querySelector("div.MuiBox-root.css-ap3adg").clientWidth
}));

let heightIncrement = -5;

while (true) {
  const adjustedHeight = height / 0.75 + heightIncrement;

  const pdfBuffer = await page.pdf({
    height: adjustedHeight,
    width: width / 0.75 + 1,
    scale: 1 / 0.75,
    printBackground: true
  });

  const pdfDoc = await pdfjsLib.getDocument({
    data: new Uint8Array(pdfBuffer)
  }).promise;

  // Get the number of pages

  if (pdfDoc.numPages === 1) {
    await fs.writeFile(filename, pdfBuffer);
    break;
  }

  console.error("We have a problem: ", filename, adjustedHeight);

  heightIncrement += 0.05;
}

const a = 2;

await page.close();

Any update on this? I tried setting the page html dimensions to a multiple of 8, and the resulting document to a multiple of 8 but still get the white border

Dear stale bot, this is not stale, you are only causing spam.

I ran into this issue the other day and as far as I can tell there does indeed seem to be something wonky with the unit conversion.

I’m using standard European poster sizes: 30 x 40 cm, 50 x 70 cm and 70 x 100 cm, which should lead to the corresponding pixel sizes 1133.86 x 1511.81 px, 1889.76 x 2645,67 px and 2645,67 x 3779,53.

However, when I run this with the options below the output pdf size is 1416  ×  1983 px. Almost exactly 75% of the expected pixel size, which is the relationship between 72 and 96.

I say exactly, because there seems to be a rounding error which seems to be the cause of the white pixel at the bottom of the document.

const options = {
    width: "50cm",
    height: "70cm",
    displayHeaderFooter: false,
    landscape: false,
    printBackground: true,
  };

Did som additional digging in the issues here and found Issue 3098 and Issue 666.

My working theory is that there is some confusion between pixels (96 per inch) and points (72 per inch) somewhere. My guess from looking at the source is that Puppeteer assumes a PPI of 96 while Chromium uses 72. Indeed, when I follow this fork, which adds the option of specifying DPI and I set DPI to 72 the pixel sizes of the output document is as expected and there is no longer any white line in the document.

This is only true for documents of size 30x40 cm though. There still seems to be some rounding errors. So for now, since these pdfs go to print, I add a margin and get the printers to cut my posters accordingly.

I finally got it working!

Every page has the correct dimensions, no gaps, no shifting, it’s perfect (I think).

This is how I am generating PDFs. This code is pretty basic and still valid:

var pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
});

But, as we all know, it’s pretty inconsistent by default. I tried a lot of different ideas, but nothing worked.

Then I stumbled upon Paper CSS.

It’s not specifically made for Puppeteer, so it still wasn’t perfect, but after that the pages didn’t shift anymore! That was something! Every page was consistently in place! There was still a bit of whitespace at the bottom, but after taking a peek at the source code I realized that they intentionally lowered each vertical dimension by 1mm because of some issues with browsers, which do not seem to apply in our case. So I manually corrected each value and removed unneccessary bloat.

Here’s the final tweaked file:

@page {
  margin: 0;
}
body {
  margin: 0;
}
.sheet {
  margin: 0;
  overflow: hidden;
  position: relative;
  box-sizing: border-box;
  page-break-after: always;
}

body.A3 .sheet {
  width: 297mm;
  height: 420mm;
}
body.A3.landscape .sheet {
  width: 420mm;
  height: 297mm;
}
body.A4 .sheet {
  width: 210mm;
  height: 297mm;
}
body.A4.landscape .sheet {
  width: 297mm;
  height: 210mm;
}
body.A5 .sheet {
  width: 148mm;
  height: 210mm;
}
body.A5.landscape .sheet {
  width: 210mm;
  height: 148mm;
}
body.letter .sheet {
  width: 216mm;
  height: 280mm;
}
body.letter.landscape .sheet {
  width: 280mm;
  height: 216mm;
}
body.legal .sheet {
  width: 216mm;
  height: 357mm;
}
body.legal.landscape .sheet {
  width: 357mm;
  height: 216mm;
}

Then I had to make sure that there weren’t any conflicting values in my own CSS code. I basically removed any and all page dimensions from my own CSS.

After that there’s only one last, but critical step: You have to add the same class as your pdf format (see above) to your body (e.g. A4) and add the class sheet to every individual page wrapper.

This is how it should look:

<body class="A4">
  <section class="sheet">Page 1</section>
  <section class="sheet">Page 2</section>
  [...]
  <section class="sheet">Page whatever</section>
</body>

Done. I hope this helps!

Hey guys!

I struggled with this problem for a long time and found a great solution too. Possibly the simplest one. In short, I resize an already printed page using PDF-LIB.

import puppeteer = require('puppeteer');
import { PDFDocument } from 'pdf-lib';

const browser = await puppeteer.launch({ headless: 'new' });
const browserPage = await browser.newPage();
const pdfPageWidthInMM = 210;
const pdfPageHeightInMM = 297;
const content = `
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
    </head>
    <body>
      Your content here...
    </body>
  </html>
`;

await browserPage.setContent(content, { waitUntil: 'networkidle0' });

const printedPdf = await browserPage.pdf({
  printBackground: true,
  pageRanges: '1',
  displayHeaderFooter: false,
  width: `${pdfPageWidthInMM}mm`,
  height: `${pdfPageHeightInMM}mm`,
});


// ============ MAGIC STARTS HERE ============

// calculate a number of PDF points in 1 mm (595.28 points = 210 mm)
// See PDF-LIB documentation: https://pdf-lib.js.org/docs/api/#a4
const sizeOf1mm = Math.trunc(595.28 / 210 * 10000) / 10000; 
const pdfDoc = await PDFDocument.load(printedPdf);

pdfDoc.getPage(0).setSize(pdfPageWidthInMM * sizeOf1mm, pdfPageHeightInMM * sizeOf1mm); // change a page size

const pdf: Unit8Array = await pdf.save();

In my case, I only resize the first page. You can resize all pages at once.

Still going, whatever I do I have 1 dead pixel line on the bottom, no way to even color it as well 😕

@malithrw unfortunately not …

Maybe line-height is the culprit. There is a discrepancy between when saving a PDF and the preview generated for print (and the screen content). I think it happens when the line-height value generates a fractional pixel value.

I’ve been facing the same issue. I think you are right in that this problem relates to rounding errors.

In the specific use case I’m dealing with, I had successfully gotten other page sizes flush with the edges by, as you mentioned, adding on fractions of a MM by trial and error.

However, I just could not get the edges flush when specifying the width/height in MM on a particular size of page, I found that specifying the width/height in pixels was the only way to get this one particular size flush.

The problem is made even worse when the document consists of multiple pages, and then like 20 pages down you’ve got 20 pixels of the previous page poking in through the top.

However, all of these problems go away entirely if you use a standard page size like A4, and specify that to puppeteer with the ‘format’ option, rather than explicit widths/heights.

Been updating whenever one is available, but the problems persist.

Edit (more info):

I’m specifying width/height using the @page rule:

@page {
    size: 210mm 297mm;
}

.page {
    page-break-after: always;
}

And then to puppeteer’s .pdf() method, the following options are passed:

{
    width: '210mm',
    height: '297mm'
}

Doesn’t make a difference with emulateMedia is ‘print’ or ‘screen’, or if margins are set or not set.