[FEATURE] 关于博文的打印

by ADMIN 18 views

描述功能需求

最近在尝试将博文打印为带书签的 PDF。算是部分成功了,但是书签无法跳转到精准的标题,只能跳转到页顶。

修改代码

./themes/FixIt/layouts/posts/single.html 中修改了部分代码:

{{- /* Custom block before post footer */ -}}
{{- block "custom-post__footer:before" . }}
{{ partial "single/print-to-pdf.html" . }}
{{ end -}}

其中 single/print-to-pdf.html 内容如下(我让 GPT 参考这篇以及这篇知乎文章)写的代码。用到的库有 pdf-lib.js。大致思路就是:

  • 先为每个 header tag 内部加一个 a href="af://nX" 的 tag 作为锚点
  • 再使用 window.print() 方法打印一份临时的 pdf(这就是博文下方的第一个按钮对应的功能)
  • 再点击第二个按钮将临时的 pdf 上传到网站上进行进一步处理。 此时会用 pdf-lib.js 找出最开始添加的锚点,然后为每个锚点指定书签 bookmarks。处理完后会自动下载 pdf。

上传 PDF 后添加书签

<!-- layouts/partials/single/print-to-pdf.html -->

<!-- 引入 pdf-lib -->
<script src="https://cdn.jsdelivr.net/npm/pdf-lib/dist/pdf-lib.min.js"></script>

<div id="print-controls" style="text-align: center; margin: 2rem 0;">
  <button onclick="window.print();" style="
    background-color: #444;
    color: white;
    padding: 0.6rem 1.2rem;
    border-radius: 8px;
    border: none;
    cursor: pointer;
    font-size: 0.95rem;
  ">🖨️ 打印当前页面为 PDF</button>
  <br /><br />

  <button id="upload-btn" style="
    background-color: #444;
    color: white;
    padding: 0.6rem 1.2rem;
    border-radius: 8px;
    border: none;
    cursor: pointer;
    font-size: 0.95rem;
  ">📂 上传 PDF 自动添加书签</button>
  <input type="file" id="pdf-upload" accept="application/pdf" style="display: none;" />
</div>

<script>
  window.addEventListener("DOMContentLoaded", () => {
    document.getElementById("upload-btn").addEventListener("click", () => {
      document.getElementById("pdf-upload").click();
    });
    document.getElementById("pdf-upload").addEventListener("change", handlePdfUpload);
  });
</script>

<!-- beforeprint:插入 af://nX 锚点并 resize ECharts -->
<script>
  window.addEventListener("beforeprint", () => {
    const headers = document.querySelectorAll(".single-title, .content h2, .content h3, .content h4");
    headers.forEach((el, i) => {
      if (el.querySelector("a.md-print-anchor")) return;
      const anchor = document.createElement("a");
      anchor.href = `af://n${i}`;
      anchor.className = "md-header-anchor md-print-anchor";
      anchor.style.display = "inline-block";
      anchor.style.height = "0.1pt";
      anchor.style.width = "0.1pt";
      anchor.style.overflow = "hidden";
      anchor.style.color = "transparent";
      el.insertBefore(anchor, el.firstChild);
    });
  });

  window.addEventListener("afterprint", () => {
    document.querySelectorAll(".md-print-anchor").forEach(a => a.remove());
  });
</script>

<!-- 上传 PDF 后添加书签 -->
<script>
  function handlePdfUpload(e) {
    const file = e.target.files[0];
    if (!file) return alert("请先选择一个 PDF 文件");

    (async () => {
      const arrayBuffer = await file.arrayBuffer();
      const pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer);
      const pages = pdfDoc.getPages();
      const context = pdfDoc.context;
      const pageHeight = pages[0].getHeight();

      const markerMap = new Map();
      for (let i = 0; i < pages.length; i++) {
        const annots = pages[i].node.Annots?.();
        if (!annots) continue;
        for (let j = 0; j < annots.size(); j++) {
          try {
            const ann = annots.lookup(j, PDFLib.PDFDict);
            const subtype = ann.get(PDFLib.PDFName.of("Subtype"));
            if (subtype?.asString() !== "/Link") continue;
            const action = ann.get(PDFLib.PDFName.of("A"));
            const uri = action?.get(PDFLib.PDFName.of("URI"))?.asString();
            const rect = ann.get(PDFLib.PDFName.of("Rect"))?.asRectangle();
            const match = /^af:\/\/(.+)$/.exec(uri);
            if (!match) continue;

            const anchorId = match[1];
            const y = pageHeight - rect.y;
            markerMap.set(anchorId, { pageIndex: i, y });
          } catch (err) {
            console.warn("annotation parse failed", err);
          }
        }
      }

      const headings = Array.from(document.querySelectorAll(".single-title, .content h2, .content h3, .content h4"))
        .map((el, i) => ({
          level: el.matches(".single-title") ? 1 : parseInt(el.tagName[1]),
          text: el.innerText.trim(),
          marker: `n${i}`
        }))
        .filter(h => markerMap.has(h.marker));

      if (headings.length === 0) {
        alert("未在 PDF 中找到任何 af:// 书签标记,请确认已插入锚点并打印。");
        return;
      }

      const outlineNodes = [];
      headings.forEach(({ text, level, marker }) => {
        const { pageIndex, y } = markerMap.get(marker);
        const page = pages[pageIndex];
        const destArray = context.obj([
          page.ref,
          PDFLib.PDFName.of("XYZ"),
          PDFLib.PDFNumber.of(0),
          PDFLib.PDFNumber.of(y),
          PDFLib.PDFNull
        ]);
        const dict = context.obj({
          Title: PDFLib.PDFHexString.fromText(text),
          Dest: destArray
        });
        const ref = context.register(dict);
        outlineNodes.push({ dict, ref, level, children: [] });
      });

      const stack = [], topLevel = [];
      outlineNodes.forEach(node => {
        while (stack.length > 0 && stack[stack.length - 1].level >= node.level) stack.pop();
        if (stack.length === 0) topLevel.push(node);
        else stack[stack.length - 1].children.push(node);
        stack.push(node);
      });

      function linkSiblings(nodes) {
        for (let i = 0; i < nodes.length; i++) {
          const current = nodes[i];
          if (i > 0) current.dict.set(PDFLib.PDFName.of("Prev"), nodes[i - 1].ref);
          if (i < nodes.length - 1) current.dict.set(PDFLib.PDFName.of("Next"), nodes[i + 1].ref);
          if (current.children.length > 0) {
            current.dict.set(PDFLib.PDFName.of("First"), current.children[0].ref);
            current.dict.set(PDFLib.PDFName.of("Last"), current.children[current.children.length - 1].ref);
            current.dict.set(PDFLib.PDFName.of("Count"), PDFLib.PDFNumber.of(current.children.length));
            linkSiblings(current.children);
          }
        }
      }

      linkSiblings(topLevel);

      const outlineDict = context.obj({
        Type: PDFLib.PDFName.of("Outlines"),
        First: topLevel[0].ref,
        Last: topLevel[topLevel.length - 1].ref,
        Count: PDFLib.PDFNumber.of(topLevel.length)
      });

      pdfDoc.catalog.set(PDFLib.PDFName.of("Outlines"), context.register(outlineDict));
      pdfDoc.catalog.set(PDFLib.PDFName.of("PageMode"), PDFLib.PDFName.of("UseOutlines"));

      const pdfBytes = await pdfDoc.save();
      const blob = new Blob([pdfBytes], { type: "application/pdf" });
      const link = document.createElement("a");
      link.href = URL.createObjectURL(blob);
      link.download = "带书签的PDF.pdf";
      link.click();
    })();
  }
</script>

echarts / jsxgraph / mathbox 在打印 pdf 的时候 只会根据浏览器的大小变动 , 而不会根据打印时纸张的大小自动 resize

为了解决这个问题,我们可以通过修改 @media print 来调整。

@media print {
  /* echarts / jsxgraph / mathbox 的容器 */
  .echarts-container {
    width: 100% !important;
    height: 100% !important;
  }

  /* echarts / jsxgraph / mathbox 的图表 */
  .echarts-graph {
    width: 100% !important;
    height: 100% !important;
  }
}

有价值的

描述功能需求

最近在尝试将博文打印为带书签的 PDF。算是部分成功了,但是书签无法跳转到精准的标题,只能跳转到页顶。

Q&A

Q: 如何添加书签到 PDF?

A: 我们可以使用 pdf-lib.js 库来添加书签。具体步骤如下:

  1. 首先,我们需要为每个 header tag 内部加一个 a href="af://nX" 的 tag 作为锚点。
  2. 然后,我们需要使用 window.print() 方法打印一份临时的 pdf。
  3. 最后,我们需要点击第二个按钮将临时的 pdf 上传到网站上进行进一步处理。 此时会用 pdf-lib.js 找出最开始添加的锚点,然后为每个锚点指定书签 bookmarks。

Q: 如何调整 echarts / jsxgraph / mathbox 在打印 pdf 的时候 只会根据浏览器的大小变动 , 而不会根据打印时纸张的大小自动 resize?

A: 我们可以通过修改 @media print 来调整。具体步骤如下:

  1. 首先,我们需要找到 echarts / jsxgraph / mathbox 的容器和图表的 CSS 类名。
  2. 然后,我们需要在 @media print 中添加以下 CSS 代码:
@media print {
  /* echarts / jsxgraph / mathbox 的容器 */
  .echarts-container {
    width: 100% !important;
    height: 100% !important;
  }

  /* echarts / jsxgraph / mathbox 的图表 */
  .echarts-graph {
    width: 100% !important;
    height: 100% !important;
  }
}

Q: 如何使用 pdf-lib.js 添加书签?

A: 我们可以使用以下代码来添加书签:

const pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer);
const pages = pdfDoc.getPages();
const context = pdfDoc.context;
const pageHeight = pages[0].getHeight();

const markerMap = new Map();
for (let i = 0; i < pages.length; i++) {
  const annots = pages[i].node.Annots?.();
  if (!annots) continue;
  for (let j = 0; j < annots.size(); j++) {
    try {
      const ann = annots.lookup(j, PDFLib.PDFDict);
      const subtype = ann.get(PDFLib.PDFName.of("Subtype"));
      if (subtype?.asString() !== "/Link") continue;
      const action = ann.get(PDFLib.PDFName.of("A"));
      const uri = action?.get(PDFLib.PDFName.of("URI"))?.asString();
      const rect = ann.get(PDFLib.PDFName.of("Rect"))?.asRectangle();
      const match = /^af:\/\/(.+)$/.exec(uri);
      if (!match) continue;

      const anchorId = match[1];
      const y = pageHeight - rect.y;
      markerMap.set(anchorId, { pageIndex: i, y });
    } catch (err) {
      console.warn("annotation parse failed", err);
    }
  }
}

const headings = Array.from(document.querySelectorAll(".single-title, .content h2, .content h3, .content h4"))
  .map((el, i) => ({
    level: el.matches(".single-title") ? 1 : parseInt(el.tagName[1]),
    text: el.innerText.trim(),
    marker: `n${i}`
  }))
  .filter(h => markerMap.has(h.marker));

if (headings.length === 0) {
  alert("未在 PDF 中找到任何 af:// 书签标记,请确认已插入锚点并打印。");
  return;
}

const outlineNodes = [];
headings.forEach(({ text, level, marker }) => {
  const { pageIndex, y } = markerMap.get(marker);
  const page = pages[pageIndex];
  const destArray = context.obj([
    page.ref,
    PDFLib.PDFName.of("XYZ"),
    PDFLib.PDFNumber.of(0),
    PDFLib.PDFNumber.of(y),
    PDFLib.PDFNull
  ]);
  const dict = context.obj({
    Title: PDFLib.PDFHexString.fromText(text),
    Dest: destArray
  });
  const ref = context.register(dict);
  outlineNodes.push({ dict, ref, level, children: [] });
});

const stack = [], topLevel = [];
outlineNodes.forEach(node => {
  while (stack.length > 0 && stack[stack.length - 1].level >= node.level) stack.pop();
  if (stack.length === 0) topLevel.push(node);
  else stack[stack.length - 1].children.push(node);
  stack.push(node);
});

function linkSiblings(nodes) {
  for (let i = 0; i < nodes.length; i++) {
    const current = nodes[i];
    if (i > 0) current.dict.set(PDFLib.PDFName.of("Prev"), nodes[i - 1].ref);
    if (i < nodes.length - 1) current.dict.set(PDFLib.PDFName.of("Next"), nodes[i + 1].ref);
    if (current.children.length > 0) {
      current.dict.set(PDFLib.PDFName.of("First"), current.children[0].ref);
      current.dict.set(PDFLib.PDFName.of("Last"), current.children[current.children.length - 1].ref);
      current.dict.set(PDFLib.PDFName.of("Count"), PDFLib.PDFNumber.of(current.children.length));
      linkSiblings(current.children);
    }
  }
}

linkSiblings(topLevel);

const outlineDict = context.obj({
  Type: PDFLib.PDFName.of("Outlines"),
  First: topLevel[0].ref,
  Last: topLevel[topLevel.length - 1].ref,
  Count: PDFLib.PDFNumber.of(topLevel.length)
});

pdfDoc.catalog.set(PDFLib.PDFName.of("Outlines"), context.register(outlineDict));
pdfDoc.catalog.set(PDFLib.PDFName.of("PageMode"), PDFLib.PDFName.of("UseOutlines"));

const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: "application/pdf" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "带书签的PDF.pdf";
link.click();

Q: 如何使用 pdf-lib.js 加载 PDF?

A: 我们可以使用以下代码来加载 PDF:

const pdfDoc = await PDFLib.PDFDocument.load(arrayBuffer);

Q: 如何使用 pdf-lib.js 保存 PDF?

A: 我们可以使用以下代码来保存 PDF:

const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: "application/pdf" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "带书签的PDF.pdf";
link.click();

Q: 如何使用 pdf-lib.js 添加书签到 PDF?

A: 我们可以使用以下代码来添加书签到 PDF:

const headings = Array.from(document.querySelectorAll(".single-title, .content h2, .content h3, .content h4"))
  .map((el, i) => ({
    level: el.matches(".single-title") ? 1 : parseInt(el.tagName[1]),
    text: el.innerText.trim(),
    marker: `n${i}`
  }))
  .filter(h => markerMap.has(h.marker));

if (headings.length === 0) {
  alert("未在 PDF 中找到任何 af:// 书签标记,请确认已插入锚点并打印。");
  return;
}

const outlineNodes = [];
headings.forEach(({ text, level, marker }) => {
  const { pageIndex, y } = markerMap.get(marker);
  const page = pages[pageIndex];
  const destArray = context.obj([
    page.ref,
    PDFLib.PDFName.of("XYZ"),
    PDFLib.PDFNumber.of(0),
    PDFLib.PDFNumber.of(y),
    PDFLib.PDFNull
  ]);
  const dict = context.obj({
    Title: PDFLib.PDFHexString.fromText(text),
    Dest: destArray
  });
  const ref = context.register(dict);
  outlineNodes.push({ dict, ref, level, children: [] });
});

const stack = [], topLevel = [];
outlineNodes.forEach(node => {
  while (stack.length > 0 && stack[stack.length - 1].level >= node.level) stack.pop();
  if (stack.length === 0) topLevel.push(node);
  else stack[stack.length - 1].children.push(node);
  stack.push(node);
});

function linkSiblings(nodes) {
  for (let i = 0; i < nodes.length; i++) {
    const current = nodes[i];
    if (i > 0) current.dict.set(PDFLib.PDFName.of("Prev"), nodes[i - 1].ref);
    if (i < nodes.length - 1) current.dict.set(PDFLib.PDFName.of("Next"), nodes[i + 1].ref);
    if (current.children.length > 0) {
      current.dict.set(PDFLib.PDFName.of("First"), current.children[0].ref);
      current.dict.set(PDFLib.PDFName.of("Last"), current.children[current.children.length - 1].