EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Printing Dozens of Government Forms from HTML with Correct Margins
Linux

Printing Dozens of Government Forms from HTML with Correct Margins

by Ton-Or ·26 Sep 2026·10 min read
Printing Dozens of Government Forms from HTML with Correct Margins

One government submission package has more than fifty sheets. Officials check every field. If even one line of text at the edge gets cut off, the documents are considered incomplete and must be resubmitted. This makes the job about getting measurements right, rather than making pages look nice.

I was building a document system for a set of government permit applications. The documents ran to more than fifty sheets, each covering one item on an assessment checklist, with a business header, an item number, a signature area at the bottom, and, on many sheets, images of supporting documents. The requirement was for staff on site to open the website and click Print in their browsers, either for the whole set or for individual sheets, with every sheet fitting within its margins without manual adjustments.

I have replaced the business name, registration number, address, signer's name, and all keys in this article with examples. The dimensions, CSS values, and code structure are from the actual project.

This is a different problem from having the browser print PDFs automatically

I have already written about generating PDFs with headless Chromium in Generating Thai PDF Quotations from HTML with Chromium. If you are building a system where the server generates the files, read that article. I will not repeat it here.

The server prints

Control every variable, but need a browser installed

  • Set margins, paper size, and page numbers directly through parameters
  • Get the same output on every machine because it comes from the same browser
  • Install and manage the browser version on the server
  • Users cannot make changes themselves and must wait for another run
Users click Print

Nothing needed on the server, but much harder to control

  • Margins come from the browser's print dialog, and users can change them
  • Edit text on the page and print again immediately
  • Design everything to fit the page instead of relying on automatic scaling
  • Check for overflow, or discover it only when the paper comes out

This article focuses on the right-hand side: everything must fit from the design stage because there is no automatic scaling step to tidy things up afterward.

How many millimeters are actually usable on one sheet?

These are the numbers I had to keep in mind throughout the project. A4 paper is 210 mm wide, and I set the left and right margins to 16 mm each. That leaves an actual printable width of 178 mm. I also set myself a rule that any frame placed on a page must be no wider than 174 mm, leaving 4 mm for frame borders and printer tolerances.

:root { --green: #14532d; --line: #d7dbd8; --sub: #4b5563; }

/* หนึ่ง .page = หนึ่งแผ่น A4 · ล่างเผื่อ 52mm ไว้ให้ช่องลงนามกับท้ายกระดาษ */
.page {
  width: 210mm;
  min-height: 297mm;
  background: #fff;
  margin: 16px auto;                  /* ระยะห่างตอนดูบนจอเท่านั้น ตอนพิมพ์เป็น 0 */
  padding: 18mm 16mm 52mm;            /* บน | ซ้ายขวา | ล่าง */
  box-shadow: 0 3px 18px rgba(0,0,0,.35);
  position: relative;
  page-break-after: always;
}
.page:last-of-type { page-break-after: auto; }

/* กรอบรูปทุกชนิดห้ามเกิน 174mm — พื้นที่พิมพ์ 178mm หักเส้นขอบและความคลาดเคลื่อน */
.tf-hold { max-width: 174mm; margin: 0 auto; overflow: hidden;
           border: 1px solid #e5e7eb; border-radius: 4px; background: #fff;
           display: flex; align-items: center; justify-content: center; }
.tf-hold img { width: 100%; height: 100%; object-fit: contain; display: block; }

Vertically, subtracting 18 mm at the top and 52 mm at the bottom from 297 mm leaves roughly 227 mm. For sheets with a full-page document image, though, I compress the heading area (more on that below) and allow the image to reach 196 mm in height, which is the limit I use throughout the project.

210 mmImage frameBottom 52 mmTop margin 18 mmpadding: 18mm 16mm 52mm (top | sides | bottom)All image frames at most 174 mm wideAllow 4 mm for borders and printer tolerancesPrintable width 178 mmLeft and right margins 16 mm eachHeight about 227 mm (297 - 18 - 52)Reserve 52 mm at bottomFor signatures and footer
A scaled-down A4 sheet: 210 mm wide, minus 16 mm margins on each side, leaves 178 mm, and image frames must be no wider than 174 mm. Vertically, an 18 mm top margin and 52 mm reserved at the bottom leave roughly 227 mm for content.
210mmA4 paper width
178mmPrintable width after margins
174mmMaximum frame width
196mmMaximum document image height
1123pxOne page's height at 96dpi

The 1123-pixel figure comes from 297 mm multiplied by 96 and divided by 25.4. I use this number as the threshold for deciding whether a page overflows, because everything on screen is measured in pixels while paper is measured in millimeters, so this project requires constant conversion between the two.

Headers and footers that repeat on every page must not use position fixed

Initially, I built a document editor that showed changes as I typed and needed headers and footers repeated on every page, like a word processor. The first approach everyone thinks of is position: fixed, pinned to the top and bottom, which looks great on screen but overlaps the content from the second page onward when actually printed.

position fixed and printing do not work together

When rendering for print, the browser breaks pages based on the normal content flow, but fixed boxes have already been removed from that flow. The result is that they are drawn over the content instead of reserving space for themselves. They cover the top paragraph of every page from the second page onward, which cannot be seen on screen and only becomes apparent when printed.

The approach that actually works is to let a table do the job: put the header in thead, the content in tbody, and the footer in tfoot, then tell the browser to repeat the table's header and footer groups. This behavior has existed since the early days of CSS for print media, and browsers handle it correctly without further intervention.

/* หัว-ท้ายซ้ำทุกหน้าแบบเวิร์ด: ให้ตารางเป็นคนทำ ไม่ใช่ position:fixed */
thead { display: table-header-group; }    /* ซ้ำบนสุดของทุกหน้า */
tfoot { display: table-footer-group; }    /* ซ้ำล่างสุดของทุกหน้า */
tr, .no-break { break-inside: avoid; }    /* ห้ามตัดกลางแถว */
.sign-block   { break-inside: avoid; }    /* บล็อกลายเซ็นห้ามหลุดไปคนละหน้ากับเนื้อหา */

/* โลโก้ทำเป็นพื้นหลังของหัวกระดาษ ไม่ฝัง base64 ซ้ำทุกหน้า ไฟล์จะบวมทันที */
.head .logo { width: 34px; height: 30px; flex: none;
              background: url('data:image/png;base64,...') center/contain no-repeat; }

The logo was an extra issue I discovered during implementation. Embedding the logo as base64 in an image tag on every page makes the file grow enormously across more than fifty sheets and noticeably slows browser rendering. Make it a background in a single CSS class, and every page shares the same one.

I use one page template function for the entire set, passing in the item number, heading, and content, and it formats everything else consistently. That way, when the header needs changing, one edit updates all fifty-plus sheets.

<?php
// ต้นแบบหนึ่งแผ่น A4 (หัวกระดาษ + เนื้อหา + ช่องลงนาม + ท้ายกระดาษ)
function doc_page($no, $title, $content, $nosign = false, $opts = []) {
?>
  <div class="page doc" spellcheck="false">
    <div class="head">
      <div class="logo"></div>
      <div class="co">
        <div class="nm">ห้างหุ้นส่วนจำกัด ตัวอย่าง การผลิต</div>
        <div class="ad">999 หมู่ 9 ต.ตัวอย่าง อ.ตัวอย่าง จ.ตัวอย่าง 10000
          &nbsp;·&nbsp; <b>ทะเบียนนิติบุคคลเลขที่</b> 0000000000000</div>
      </div>
    </div>
    <div class="hrule"></div>
    <div class="meta">
      <span>เลขที่เอกสาร <span class="f">&nbsp;</span></span>
      <?php if (empty($opts['nodate'])): ?><span>วันที่ <span class="f">&nbsp;</span></span><?php endif; ?>
    </div>
    <?php if ($no !== '' || $title !== ''): ?>
    <div class="title">
      <?php if ($no !== ''): ?><div class="no">ข้อ <?= $no ?></div><?php endif; ?>
      <div class="tt"><?= $title ?></div>
    </div>
    <div class="subtitle">ประกอบคำขออนุญาต · แบบตรวจประเมินสถานที่ผลิต (ขั้นตอนที่ 2)</div>
    <?php endif; ?>
    <?= $content ?>
    <?php if (!$nosign): ?>
    <div class="sign">
      <div class="sig">
        <div class="ln">&nbsp;</div>
        <div>( ................................................ )</div>
        <div class="rl">ผู้มีอำนาจลงนาม / ผู้จัดทำ</div>
      </div>
    </div>
    <?php endif; ?>
    <div class="foot"><span>ห้างหุ้นส่วนจำกัด ตัวอย่าง การผลิต</span></div>
  </div>
<?php
}
How one sheet is builtTopic arrayNumber + title + content type->Helper functionSelect frame by type->Page templateHeader + signature + footer->CSS auto-compressionMake room for the image->Print dialogUser clicks Print

Four image frame types, based on their contents

This is the core of the project. I once tried using the same frame for everything, which cropped the edges of document images, and in a government submission that means incomplete documents that must be resubmitted. I eventually split the frames into four types based on what they would contain.

  1. Process photo frames Four frames per sheet, each roughly 85 by 64 mm, for photos of work processes. These images can be cropped because they have no information at the edges.
  2. Attachment frames One image per sheet, with sequential numeric filename suffixes and sheets added automatically as more images are added. If there is no image yet, display a cover sheet so the actual document can be attached after it.
  3. Actual-size frames Specify width and height directly in millimeters, for documents that must be printed at their original size, such as copies of standard-sized plastic cards.
  4. Largest proportional frames Leave dimensions unlocked and let the image grow as large as the page allows while preserving its original proportions, for large documents whose every corner must remain visible.

The last two types took the most time because they go against a web developer's instinct to make images fill their frames neatly.

/** กรอบขนาดจริงเป็นมิลลิเมตร — ใช้เฉพาะเอกสารที่ต้องพิมพ์เท่าของจริง */
function truefig($key, $wmm, $hmm, $fallback = '') {
  global $STEPDIR;
  $up  = is_file($STEPDIR.'/'.$key.'.jpg');
  $src = $up ? ('doc.php?stepimg='.$key.'&v='.filemtime($STEPDIR.'/'.$key.'.jpg'))
             : ($fallback ? assetsrc($fallback) : '');
  // อัตราส่วนกรอบคำนวณจากขนาดจริง เพื่อส่งให้ตัวปรับรูปฝั่งเบราว์เซอร์ใช้เป็นกรอบครอป
  $ar  = round(((float)$wmm) / max(0.1, (float)$hmm), 4);
  $o  = '<div class="stepbox truebox" data-key="'.$key.'" data-ar="'.$ar.'" data-reload="1">';
  $o .= '<div class="tf-hold" style="width:'.$wmm.'mm;height:'.$hmm.'mm" id="fr_'.$key.'">';
  $o .= $src ? '<img src="'.$src.'" alt="สำเนาเอกสาร">' : '<span class="sb-empty">ยังไม่มีรูป</span>';
  return $o.'</div></div>';
}

/** กรอบโตสุดเท่าที่หน้ารับได้ ตามสัดส่วนรูปจริง — ไม่ตัดขอบเด็ดขาด */
function docfig($key, $fallback = '', $maxh = '196mm', $ar = '1.3333') {
  global $STEPDIR;
  $up  = is_file($STEPDIR.'/'.$key.'.jpg');
  $src = $up ? ('doc.php?stepimg='.$key.'&v='.filemtime($STEPDIR.'/'.$key.'.jpg'))
             : ($fallback ? assetsrc($fallback) : '');
  $o  = '<div class="stepbox docbox" data-key="'.$key.'" data-ar="'.$ar.'" data-reload="1">';
  $o .= '<div class="df-hold" style="max-height:'.$maxh.'" id="fr_'.$key.'">';
  $o .= $src ? '<img style="max-height:'.$maxh.'" src="'.$src.'" alt="สำเนาเอกสาร">'
             : '<span class="sb-empty">กดปุ่มด้านล่างเพื่อแนบรูปเอกสาร</span>';
  return $o.'</div></div>';
}
Images of government documents must always show the entire sheet

The header, code numbers, fields on both sides, date, signature, and stamp must all remain visible in every corner because officials check copies field by field. If an edge is cut off, the document is considered incomplete. When choosing between "large" and "complete," choose complete first and scale the image down. Frames displaying document images must always use object-fit set to contain or max-width, never cover.

Automatically compress headings with has() to make room for images

On sheets with a full-page document image, the space taken up by the heading, text below it, and signature area squeezes the image until it becomes too small to read. I did not want to add special classes to individual sheets, so I used the :has() selector to let CSS look inside each page, identify the type of image box, and compress the heading area automatically.

/* หน้าไหนมีกล่องรูปเอกสาร ให้บีบส่วนหัวลงเองโดยไม่ต้องแปะคลาสพิเศษทีละแผ่น */
.page:has(.attbox), .page:has(.truebox), .page:has(.docbox) { padding-bottom: 26mm; }
.page:has(.attbox) .subtitle, .page:has(.truebox) .subtitle,
.page:has(.docbox) .subtitle { display: none; }
.page:has(.attbox) .title,    .page:has(.docbox) .title    { margin: 8px 0 2px; }
.page:has(.attbox) .title .tt,.page:has(.docbox) .title .tt{ font-size: 16.5px; }
.page:has(.attbox) .sheetlabel,.page:has(.docbox) .sheetlabel { font-size: 13px; margin: 4px 0 6px; }
.page:has(.attbox) .sign,     .page:has(.docbox) .sign     { bottom: 10mm; }
.page:has(.attbox) .sig .rl,  .page:has(.docbox) .sig .rl  { display: none; }  /* เหลือบรรทัดเดียว */

/* รูปเอกสารเลยสูงได้ถึง 196mm บนแผ่นที่ถูกบีบแล้ว */
.stepbox.attbox .sb-img img { width: auto; height: auto; max-width: 100%;
                              max-height: 196mm; border: 1px solid #e5e7eb; border-radius: 4px; }
.stepbox.attbox .sb-img.empty { height: 186mm; border: 1.5px dashed #cbd5e1;
                                border-radius: 10px; background: #fff; }

What I like about this approach is that it keeps layout knowledge in one place in CSS instead of scattering it through the code that generates content. Anyone adding a new topic only needs to choose the right frame function, and the page handles the compression itself.

Check overflow with automation, not by eye

With more than fifty sheets, opening each one and judging overflow by eye would be too much work, and I would certainly make mistakes. I wrote two short Playwright scripts instead. The first checks two things: which pages exceed one sheet in height, and which elements extend beyond the printable boundaries.

// over.js — ตรวจหน้าล้นและของหลุดขอบ ทีเดียวทั้งเล่ม
const r = await page.evaluate(() => {
  const MM = 96 / 25.4, over = [], bleed = [];
  document.querySelectorAll('.page').forEach((pg, i) => {
    const h = pg.getBoundingClientRect().height;
    if (h > 1124) over.push((i + 1) + ':' + Math.round(h) + 'px');   // 297mm ที่ 96dpi = 1123
  });
  // ของที่ยื่นออกไปเลยเส้นขอบพิมพ์ (เผื่อ 15mm) — ข้ามของที่ไม่ถูกพิมพ์อยู่แล้ว
  document.querySelectorAll('.page').forEach((pg, i) => {
    const pr = pg.getBoundingClientRect();
    pg.querySelectorAll('*').forEach(el => {
      const r = el.getBoundingClientRect();
      if (r.width > 0 &&
          (r.left < pr.left + 15 * MM - 2 || r.right > pr.right - 15 * MM + 2) &&
          !el.closest('.noprint') && el.className !== 'page doc')
        bleed.push((i + 1) + ':' + (el.className || el.tagName));
    });
  });
  return { over, bleed: [...new Set(bleed)].slice(0, 20),
           n: document.querySelectorAll('.page').length };
});
console.log(JSON.stringify(r, null, 1));

The second measures the width and height of each page's frames and images in millimeters, along with the remaining space above the signature area. I use it when tuning frame sizes because it tells me how many more millimeters I can enlarge an image before it hits the signature.

// diag.js — รายงานขนาดจริงเป็นมิลลิเมตร ไม่ต้องเดาจากพิกเซล
const MM = 96 / 25.4;
const boxes = [...pg.querySelectorAll('.tf-hold, .stepbox.attbox .sb-img')].map(el => {
  const er = el.getBoundingClientRect();
  const im = el.querySelector('img');
  const ir = im ? im.getBoundingClientRect() : null;
  return {
    box: Math.round(er.width / MM) + 'x' + Math.round(er.height / MM),      // ขนาดกรอบ
    img: ir ? Math.round(ir.width / MM) + 'x' + Math.round(ir.height / MM) : null,
    nat: im ? im.naturalWidth + 'x' + im.naturalHeight : null,              // ขนาดไฟล์จริง
  };
});
// ที่ว่างระหว่างของชิ้นล่างสุด กับขอบบนของช่องลงนาม -> ขยายรูปได้อีกกี่ mm
const lastBottom = Math.max(0, ...[...pg.querySelectorAll('.stepbox, .figbox')]
  .map(e => e.getBoundingClientRect().bottom));
const freeBelow = Math.round((signTop - lastBottom) / MM);
Run the checks every time before calling it done

These two scripts finish in a few seconds, so I have made it a habit to run the overflow check whenever I change any CSS related to page layout. The expected result is an empty list in both fields. If a class name appears in the out-of-bounds field, something already extends beyond the printable area, without even opening the print dialog.

Print individual sheets without splitting files

The most common request from staff on site is, "Can I reprint just one sheet?" because they entered an incorrect number in a single field. I did not want to create confusion by splitting each sheet into a separate file, so I added a small button in the corner of each sheet that applies a class to that page alone and opens the print dialog.

@media print {
  body { background: #fff; }
  .toolbar, .hint, .noprint { display: none !important; }
  *:focus { outline: none !important; }
  .page { width: 210mm; min-height: 297mm; margin: 0; padding: 18mm 16mm 52mm; box-shadow: none; }
  @page { size: A4; margin: 0; }

  /* โหมดพิมพ์ทีละแผ่น: ซ่อนทุกหน้า เหลือเฉพาะหน้าที่ติดคลาส printme */
  body.printone .page { display: none !important; }
  body.printone .page.printme { display: block !important; page-break-after: auto !important; }
}

/* ปุ่มพิมพ์เฉพาะหน้านี้ โผล่ตอนเอาเมาส์ไปวางบนแผ่น และไม่ถูกพิมพ์ออกไปด้วย */
.pgprint { opacity: .35; transition: opacity .15s; }
.page:hover .pgprint { opacity: 1; }

The JavaScript that triggers this is very short, but there is one detail to watch: remove the classes after the print dialog closes. Otherwise, every other page disappears from the user's view, and they will think the website is broken.

function printOne(btn) {
  const pg = btn.closest('.page');
  document.body.classList.add('printone');
  pg.classList.add('printme');
  // คืนสภาพเสมอ ไม่ว่าผู้ใช้จะกดพิมพ์จริงหรือกดยกเลิก
  const restore = () => {
    document.body.classList.remove('printone');
    pg.classList.remove('printme');
    window.removeEventListener('afterprint', restore);
  };
  window.addEventListener('afterprint', restore);
  window.print();
  setTimeout(restore, 1500);      // เบราว์เซอร์บางตัวไม่ยิง afterprint ต้องมีตาข่ายรับ
}

For high-resolution images, do not push the browser canvas beyond its limits

Another thing I often need to send officials is an image of a document sheet, rather than a PDF file. I previously used a library that converts the DOM to a browser canvas, with selectable resolutions from 300 to 2400 dots per inch. It worked, but at high resolutions it was extremely slow and hit the browser's canvas size limit.

A faster approach that delivers the full resolution is to have the browser print to PDF first, then render that PDF as an image with a Python library.

# แปลงแผ่นเอกสารเป็นรูปความละเอียดสูง โดยไม่ต้องฝืนพื้นที่วาดของเบราว์เซอร์
import fitz          # PyMuPDF

doc = fitz.open("document.pdf")
for i, page in enumerate(doc, 1):
    pix = page.get_pixmap(dpi=2400)          # ไม่มีเพดานแบบ canvas ในเบราว์เซอร์
    pix.save("sheet_%02d_2400dpi.jpg" % i)
print("เรนเดอร์ %d แผ่นเสร็จ" % len(doc))

The point I want to emphasize is that browsers are good at page layout but not at converting pages into high-resolution images, while PDF libraries are good at converting pages into images but not at page layout. Give each the right job, and the two work well together.

The principles I follow for this kind of work

I followed three principles throughout this project. First, think in millimeters from the first line of CSS instead of starting with pixels and converting later. Second, always let tables handle headers and footers that must repeat on every page, with no exceptions. Third, every layout change must be followed by an automated overflow check, because this type of error is not visible by eye on screen.

The rest, such as design, font size, and header colors, comes down to preference and the receiving agency's requirements.

P.S. I still do not have a clean enough way to enforce the margin settings that users can change in the browser's print dialog. For now, I put instructions on the web page telling users to set the margins to zero and turn off the browser's headers and footers. If anyone has a better approach, let me know, and I will come back and update this article.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first