跳到正文
DEV Community· Raspberry Rondel·· 3 小時前AI 評分61

Playwright for Python 如何拍攝 Headless Chromium 完整頁面截圖並修正中日文及 Emoji 字體問題

Full-Page Screenshots of Chinese, Japanese and Emoji Pages with Headless Chromium (No Tofu Boxes)

AI 導讀

文章作者以 Playwright for Python 示範 Headless Chromium 完整頁面截圖流程,處理中日韓文字及 Emoji 缺字、延遲載入內容遺漏和繁體中文套用日文字形三個問題。

正文

If you take website screenshots with headless Chromium for visual QA, monitoring or archiving, two problems show up the moment the pages aren't plain English:

  1. Tofu boxes. Chinese, Japanese, Korean and emoji come out as empty rectangles (□□□), because the server or container has no font for them.
  2. Half-empty full-page shots. The page is the right height, but images further down are grey placeholders and "load more" content never appears, because nothing scrolled the page.

There's also a subtler third one: the text renders, but Traditional Chinese is drawn with the Japanese font, so some characters and punctuation have the wrong regional shape.

This post reproduces all three on a small local test page and fixes them with Playwright for Python. Everything below was run, and the output is copied from that run.

Test setup: Linux, Python 3.13, Playwright for Python 1.64.0 with its bundled Chromium headless shell (156.0.8078.4), and the Debian/Ubuntu packages fonts-noto-cjk and fonts-noto-color-emoji.

The test page

site/index.html has a title with an emoji, one paragraph each in Traditional Chinese, Simplified Chinese, Japanese and Korean, an emoji line (including a skin-tone modifier, a flag and a ZWJ family), and then:

  • two images with native loading="lazy",
  • two images that use the common JavaScript pattern (data-src swapped in by an IntersectionObserver),
  • an "infinite scroll" sentinel that appends a new section (with an image) three times as you reach the bottom.

The <html> tag says lang="zh-Hant", and the Simplified Chinese paragraph says lang="zh-Hans". Some real sites use script subtags like these.

<html lang="zh-Hant">
...
<p id="zh-Hant">繁體中文:臺灣的「鐵路便當」很好吃。數字 0123456789,標點「」、。!</p>
<p id="zh-Hans" lang="zh-Hans">简体中文:这是一个用于测试网页截图的页面。</p>
<p id="ja" lang="ja">日本語:ウェブページのスクリーンショットをテストします。</p>
<p id="ko" lang="ko">한국어: 웹 페이지 스크린샷 테스트입니다.</p>
<p id="emoji">Emoji: 😀 🎉 👍🏽 🇹🇼 👨‍👩‍👧 ❤️</p>
...
<img class="js-lazy" data-src="img/3.svg" alt="js lazy image 3">

The full page, the images and a script that runs every case are in the companion folder (site/, run_demo.sh).

The script

shoot.py does four things in this order:

  1. Scrolls down in 600 px steps with a 400 ms pause, until the page height stops growing for two rounds at the bottom (or a time/height limit is hit), then scrolls back to the top so sticky headers sit where a visitor would see them.
  2. Waits for images that are still loading (with a timeout) and for document.fonts.ready.
  3. Reports fonts. It asks Chromium, through the DevTools Protocol method CSS.getPlatformFontsForNode, which installed font actually drew each element you list, and how many glyphs came from each.
  4. Captures with full_page=True and animations="disabled".
"""Full-page screenshot with headless Chromium (Playwright for Python).

- scrolls the page first so lazy-loaded images and infinite-scroll content load
- waits for images and web fonts before capturing
- reports which real font Chromium used for each element you name (via CDP),
  so you can catch tofu boxes before you look at the PNG

Usage:
  python shoot.py URL out.png [--no-scroll] [--locale zh-TW] [--fallback-lang zh_TW.UTF-8]
                  [--check "#id1,#id2"]
"""
import argparse
import os
import time

from playwright.sync_api import sync_playwright

SCROLL_JS = """
async ({ step, pauseMs, maxSecs, maxHeight }) => {
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const height = () => Math.max(document.documentElement.scrollHeight,
                                document.body ? document.body.scrollHeight : 0);
  const start = Date.now();
  let y = 0, lastHeight = -1, stableRounds = 0;
  while (Date.now() - start < maxSecs * 1000) {
    y = Math.min(y + step, height());
    window.scrollTo(0, y);
    await sleep(pauseMs);
    const h = height();
    if (y + window.innerHeight >= h) {          // reached the bottom
      stableRounds = (h === lastHeight) ? stableRounds + 1 : 0;
      if (stableRounds >= 2 || h >= maxHeight) break;   // nothing new loaded
    }
    lastHeight = h;
  }
  window.scrollTo(0, 0);                         // sticky headers back in place
  return { height: height(), seconds: (Date.now() - start) / 1000 };
}
"""

WAIT_ASSETS_JS = """
async (timeoutMs) => {
  const imgs = [...document.images].filter((i) => !i.complete);
  const each = imgs.map((i) => new Promise((r) => { i.onload = i.onerror = r; }));
  await Promise.race([Promise.all(each), new Promise((r) => setTimeout(r, timeoutMs))]);
  if (document.fonts) await document.fonts.ready;
}
"""

IMG_STATS_JS = """
() => {
  const imgs = [...document.images];
  const loaded = imgs.filter((i) => i.complete && i.naturalWidth > 0).length;
  return { images: imgs.length, loaded };
}
"""


def platform_fonts(page, selectors):
    """Ask Chromium which installed fonts it actually used to draw each element."""
    cdp = page.context.new_cdp_session(page)
    cdp.send("DOM.enable")
    cdp.send("CSS.enable")
    root = cdp.send("DOM.getDocument")["root"]["nodeId"]
    report = {}
    for sel in selectors:
        node = cdp.send("DOM.querySelector", {"nodeId": root, "selector": sel})["nodeId"]
        if not node:
            report[sel] = "not found"
            continue
        res = cdp.send("CSS.getPlatformFontsForNode", {"nodeId": node})
        fonts = [f"{f['familyName']} ({f['glyphCount']})" for f in res["fonts"]]
        report[sel] = ", ".join(fonts) or "(no text)"
    return report


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("url")
    ap.add_argument("out")
    ap.add_argument("--no-scroll", action="store_true")
    ap.add_argument("--locale", default="zh-TW")
    ap.add_argument("--width", type=int, default=1280)
    ap.add_argument("--height", type=int, default=800)
    ap.add_argument("--max-height", type=int, default=20000)
    ap.add_argument("--fallback-lang", default="",
                    help="LANG for the browser process, e.g. zh_TW.UTF-8; picks the CJK "
                         "fallback face for text that has no lang= of its own")
    ap.add_argument("--check", default="", help="comma-separated CSS selectors to font-check")
    args = ap.parse_args()

    with sync_playwright() as p:
        env = {**os.environ, "LANG": args.fallback_lang} if args.fallback_lang else None
        browser = p.chromium.launch(env=env)
        context = browser.new_context(
            viewport={"width": args.width, "height": args.height},
            locale=args.locale,          # navigator.language / Accept-Language (not the fallback font)
            device_scale_factor=1,
        )
        page = context.new_page()
        t0 = time.time()
        resp = page.goto(args.url, wait_until="load", timeout=60_000)
        print("status:", resp.status if resp else None)

        if not args.no_scroll:
            info = page.evaluate(SCROLL_JS, {"step": 600, "pauseMs": 400,
                                             "maxSecs": 15, "maxHeight": args.max_height})
            print(f"scrolled: height={info['height']}px in {info['seconds']:.1f}s")
        page.evaluate(WAIT_ASSETS_JS, 10_000)
        print("images:", page.evaluate(IMG_STATS_JS))

        if args.check:
            for sel, fonts in platform_fonts(page, args.check.split(",")).items():
                print(f"font {sel}: {fonts}")

        page.screenshot(path=args.out, full_page=True, animations="disabled", timeout=120_000)
        print(f"saved {args.out} in {time.time() - t0:.1f}s")
        browser.close()


if __name__ == "__main__":
    main()

Install and run:

python3 -m venv .venv && . .venv/bin/activate
pip install playwright==1.64.0
playwright install chromium
python shoot.py https://example.com/ out.png --check "h1,p"

Problem 1: tofu boxes in a slim container

To simulate a slim image without having to build one, the demo gives Chromium a private fonts.conf that only lists one Latin font (DejaVu Sans), via the FONTCONFIG_FILE environment variable:

FONTCONFIG_FILE=$PWD/slim-fonts/fonts.conf \
  python shoot.py "$URL" a-slim-noscroll.png --no-scroll \
  --check "#title,#zh-Hant,#zh-Hans,#ja,#ko,#emoji"
== A: slim fonts, no scrolling ==
status: 200
images: {'images': 4, 'loaded': 2}
font #title: DejaVu Sans (15)
font #zh-Hant: DejaVu Sans (39)
font #zh-Hans: DejaVu Sans (21)
font #ja: DejaVu Sans (28)
font #ko: DejaVu Sans (23)
font #emoji: DejaVu Sans (25)
saved a-slim-noscroll.png in 0.2s

In the screenshot every CJK character is a box, and so is most of the emoji line. The two emoji DejaVu Sans happens to cover (😀 and ❤) come out as black-and-white outlines instead of colour emoji. Note what the font report says: the glyphs are still "counted" against DejaVu Sans, which has no CJK glyphs at all. So the check isn't "is there a font?", it's "is CJK text being drawn by a font that can't contain CJK?" If your Chinese paragraph reports only a Latin family, you have tofu, and you can fail the job before anyone looks at the PNG.

Fix: install CJK and emoji fonts in the image and rebuild the font cache.

RUN apt-get update && apt-get install -y --no-install-recommends \
      fonts-noto-cjk fonts-noto-color-emoji \
    && fc-cache -f && rm -rf /var/lib/apt/lists/*

With the normal system fonts:

== B: CJK + emoji fonts, no scrolling ==
status: 200
images: {'images': 4, 'loaded': 2}
font #title: Arimo (1), Noto Sans CJK JP (13), Noto Color Emoji (1)
font #zh-Hant: Arimo (11), Noto Sans CJK JP (28)
font #zh-Hans: Noto Sans CJK JP (21)
font #ja: Noto Sans CJK JP (28)
font #ko: Arimo (6), NanumGothic (17)
font #emoji: Arimo (12), Noto Color Emoji (6)
saved b-fonts-noscroll.png in 0.3s

No more boxes, and the emoji (including 👍🏽, 🇹🇼 and the ZWJ family) come from Noto Color Emoji. But look at the Chinese lines: both the Traditional and the Simplified paragraph were drawn with Noto Sans CJK JP. That's problem 3, which we'll come back to. First, the missing images.

Problem 2: lazy-loaded content is missing

Run B didn't scroll. The screenshot is 3,207 px tall: the two native lazy images near the top loaded, the two data-src images are grey boxes showing their alt text, and none of the three "infinite scroll" sections exist. full_page=True captures the whole document, but it doesn't scroll the way a visitor does, so IntersectionObserver callbacks for content further down never fire.

With the scroll step:

== C: CJK + emoji fonts, scroll first ==
status: 200
scrolled: height=5358px in 3.6s
images: {'images': 7, 'loaded': 7}
...
saved c-fonts-scroll.png in 4.0s

All 7 images loaded, and the page grew from 3,207 to 5,358 px as the three batches were appended. The loop doesn't stop at the first time it reaches the bottom: it waits until the height has stayed the same for two rounds, because infinite-scroll pages add content a moment after you arrive (the test page waits 300 ms).

Things that matter on real pages:

  • Always cap it. Some feeds never end. The script stops after 15 seconds or 20,000 px, whichever comes first. Pick limits that fit your pages.
  • Scroll back to the top before capturing, or sticky headers and "back to top" buttons end up in odd places.
  • networkidle is not a substitute. Lazy content only starts loading after the scroll, so an idle network before scrolling tells you nothing about it.
  • Disable animations (animations="disabled"), so carousels and fade-ins are captured in a stable state.

Problem 3: Chinese drawn with the Japanese font

Chinese, Japanese and Korean share a lot of code points (Han unification), but each region has its own preferred glyph shapes, and punctuation is placed differently: in Traditional Chinese fonts , and 。 usually sit in the centre of the square, while Japanese fonts put them in the lower left. Noto Sans CJK ships TC, SC, HK, JP and KR versions of the same characters, and Chromium has to pick one.

In run B, Chromium picked JP for both lang="zh-Hant" and lang="zh-Hans". We then tried a few variations on the same setup (runs B and D to G in run_demo.sh):

What we changed Font Chromium picked for the Chinese text
lang="zh-Hant" / lang="zh-Hans" (original page) Noto Sans CJK JP for both
lang="zh-TW" / lang="zh-CN" Noto Sans CJK TC / Noto Sans CJK SC
No lang on <html> (Traditional paragraph), browser started with the default LANG=C.UTF-8 Noto Sans CJK JP
No lang on <html> (Traditional paragraph), browser started with LANG=zh_TW.UTF-8 Noto Sans CJK TC
No lang on <html> (Traditional paragraph), Playwright locale="zh-TW" / "ja-JP" / "en-US" Noto Sans CJK JP in all three
Explicit font-family per :lang() the font you named

This was one Chromium build on one Linux font setup, so treat the table as "check yours" rather than a rule, but it shows two useful things. In this setup, script-only tags (zh-Hant, zh-Hans) didn't pick the Chinese faces, while region tags (zh-TW, zh-CN) did. And Playwright's locale option changes navigator.language and Accept-Language, but not the fallback font.

If you own the page, use region tags or name the faces explicitly. site/fixed.html does both:

:lang(zh-TW) { font-family: "Noto Sans CJK TC", "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif; }
:lang(zh-CN) { font-family: "Noto Sans CJK SC", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; }
:lang(ja)    { font-family: "Noto Sans CJK JP", "Noto Sans JP", "Hiragino Sans", "Yu Gothic", sans-serif; }
:lang(ko)    { font-family: "Noto Sans CJK KR", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; }
== D: fixed page (lang=zh-TW / zh-CN + :lang() CSS), scroll first ==
status: 200
scrolled: height=5358px in 3.6s
images: {'images': 7, 'loaded': 7}
font #title: Noto Sans CJK TC (14), Noto Color Emoji (1)
font #zh-Hant: Noto Sans CJK TC (39)
font #zh-Hans: Noto Sans CJK SC (21)
font #ja: Noto Sans CJK JP (28)
font #ko: Noto Sans CJK KR (23)
font #emoji: Noto Sans CJK TC (12), Noto Color Emoji (6)
saved d-fixed-page.png in 3.9s

Each language now gets its own face. (The stack also lists the macOS and Windows names, so real visitors get the right fonts too.)

If you don't own the page and it has no lang attribute, the browser process's LANG decides which face fills in. shoot.py --fallback-lang zh_TW.UTF-8 passes that to chromium.launch(env=...):

== E1: page without lang=, browser started with default LANG ==
font #zh-Hant: Arimo (11), Noto Sans CJK JP (28)
== E2: page without lang=, browser started with LANG=zh_TW.UTF-8 ==
font #zh-Hant: Arimo (11), Noto Sans CJK TC (28)

This only helps text that has no lang of its own. A page that declares lang="zh-Hant" still got JP in our test, and we deliberately don't inject CSS into pages we don't own: for monitoring and archiving, the screenshot should show the page as its authors shipped it.

Checklist

  • [ ] Install fonts-noto-cjk and fonts-noto-color-emoji (or equivalents) and run fc-cache -f
  • [ ] Font-check a few CJK elements with CSS.getPlatformFontsForNode; fail the job if CJK text is drawn only by a Latin font
  • [ ] For your own pages: use zh-TW / zh-CN / zh-HK region tags or :lang() font stacks
  • [ ] For pages without lang: start the browser with the LANG of the region you expect
  • [ ] Scroll in steps until the height stops growing, with a time and height cap, then scroll back to the top
  • [ ] Wait for pending images and document.fonts.ready; capture with animations="disabled"
  • [ ] Respect robots.txt and the site's terms, and screenshot for QA, monitoring or archiving, not to republish other people's content

This article was written with AI assistance. The code was run and the output above is copied from that run.

來源:DEV Community · dev.to