@akairos/typeset-studio 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +20 -0
  2. package/dist/assets/{index-Dn5YtK3W.js → index-CrJAVx3W.js} +17 -17
  3. package/dist/cover/cover.js +124 -0
  4. package/dist/editor/editor.js +1 -0
  5. package/dist/index.html +1 -1
  6. package/dist/lib/adapter.js +2 -1
  7. package/dist/lib/{canvas-renderer-wce6xgb2.js → canvas-renderer-hpe2bqkt.js} +3 -2
  8. package/dist/lib/{font-loader-j28mt43t.js → font-loader-cft81crk.js} +2 -1
  9. package/dist/lib/index-2vgqm8ms.js +167 -0
  10. package/dist/lib/{index-qszn98j2.js → index-3ptjkctd.js} +14 -2
  11. package/dist/lib/{index-y2tk7642.js → index-51d5rpf7.js} +11 -5
  12. package/dist/lib/index-94yttj49.js +34 -0
  13. package/dist/lib/{index-697fkv5y.js → index-dz9pmgbk.js} +12 -2
  14. package/dist/lib/index-nwch18wp.js +18 -0
  15. package/dist/lib/{index-6c43c7h4.js → index-xg6pyfw6.js} +1 -163
  16. package/dist/lib/index.js +7 -4
  17. package/dist/lib/render-epbcmzxe.js +14 -0
  18. package/dist/lib/server-font.js +9 -0
  19. package/package.json +16 -2
  20. package/src/adapter.test.ts +41 -0
  21. package/src/adapter.ts +5 -2
  22. package/src/core/article-cover.test.ts +47 -0
  23. package/src/core/article-cover.ts +97 -0
  24. package/src/core/canvas-draw.ts +9 -1
  25. package/src/core/types.ts +1 -0
  26. package/src/cover.ts +10 -0
  27. package/src/server/index.ts +1 -1
  28. package/src/server/routes/render.ts +9 -0
  29. package/src/server-font.test.ts +46 -0
  30. package/src/server-font.ts +23 -0
  31. package/src/web/components/CoverControls.tsx +22 -0
  32. package/src/web/components/Header.tsx +1 -1
  33. package/src/web/editor.ts +1 -0
  34. package/dist/lib/render-064weavx.js +0 -11
@@ -0,0 +1,124 @@
1
+ // src/core/article-cover.ts
2
+ var ARTICLE_COVER_FONT = Object.freeze({
3
+ id: "publishing-noto-cjk-regular-v1",
4
+ sha256: "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a",
5
+ family: "Publishing Cover"
6
+ });
7
+ var ARTICLE_COVER_IMAGE_KEY = "article_cover_image";
8
+ var DEFAULT_ARTICLE_COVER_CONTROLS = Object.freeze({
9
+ backgroundColor: "#ffffff",
10
+ titleColor: "#171717",
11
+ fontSize: 120,
12
+ textAlign: "left",
13
+ safePadding: 120
14
+ });
15
+ function normalizeArticleCoverTitle(raw) {
16
+ return raw.trim().replace(/^(?:#+\s*)+/, "").trim();
17
+ }
18
+ function object(value) {
19
+ if (!value || typeof value !== "object" || Array.isArray(value))
20
+ throw new RangeError("Invalid article cover object");
21
+ return value;
22
+ }
23
+ function exactKeys(value, keys) {
24
+ if (Object.keys(value).length !== keys.length || Object.keys(value).some((key) => !keys.includes(key)))
25
+ throw new RangeError("Unsupported article cover fields");
26
+ }
27
+ function color(value) {
28
+ if (typeof value !== "string" || !/^#[a-f\d]{6}$/i.test(value))
29
+ throw new RangeError("Article cover colors must be six-digit hex");
30
+ return value.toLowerCase();
31
+ }
32
+ function validateArticleCoverControls(value) {
33
+ const input = object(value);
34
+ exactKeys(input, ["backgroundColor", "titleColor", "fontSize", "textAlign", "safePadding"]);
35
+ if (!Number.isInteger(input.fontSize) || input.fontSize < 80 || input.fontSize > 200)
36
+ throw new RangeError("Article cover fontSize must be an integer between 80 and 200");
37
+ if (!Number.isInteger(input.safePadding) || input.safePadding < 60 || input.safePadding > 200)
38
+ throw new RangeError("Article cover safePadding must be an integer between 60 and 200");
39
+ if (!["left", "center", "right"].includes(input.textAlign))
40
+ throw new RangeError("Invalid article cover textAlign");
41
+ return {
42
+ backgroundColor: color(input.backgroundColor),
43
+ titleColor: color(input.titleColor),
44
+ fontSize: input.fontSize,
45
+ textAlign: input.textAlign,
46
+ safePadding: input.safePadding
47
+ };
48
+ }
49
+ function createArticleCoverConfig(value) {
50
+ const input = object(value);
51
+ exactKeys(input, ["id", "name", "controls", "assetId"]);
52
+ const uuid = /^[a-f\d]{8}(-[a-f\d]{4}){3}-[a-f\d]{12}$/i;
53
+ if (typeof input.id !== "string" || !uuid.test(input.id) || typeof input.assetId !== "string" || !uuid.test(input.assetId))
54
+ throw new RangeError("Article cover and asset ids must be UUIDs");
55
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.trim().length > 80)
56
+ throw new RangeError("Article cover name must contain 1 to 80 characters");
57
+ const controls = validateArticleCoverControls(input.controls);
58
+ const safeArea = { top: controls.safePadding, right: 1000 + controls.safePadding, bottom: controls.safePadding, left: controls.safePadding };
59
+ const font = { fontFamily: ARTICLE_COVER_FONT.family, fontSize: controls.fontSize, minFontSize: controls.fontSize, fontWeight: 400, lineHeight: 1.2, letterSpacing: 0 };
60
+ return {
61
+ id: input.id,
62
+ name: input.name.trim(),
63
+ slot: "article_cover",
64
+ aspectRatio: "wechat-cover-2.35",
65
+ algorithm: {
66
+ baseFontSize: controls.fontSize,
67
+ scaleRatio: 1,
68
+ lineHeightMultiplier: 1.2,
69
+ obstacleGap: 0,
70
+ targetFillRatio: 1,
71
+ contentBottomGap: 0,
72
+ textAlign: controls.textAlign,
73
+ verticalAlign: "center",
74
+ safeArea
75
+ },
76
+ theme: {
77
+ bgColor: controls.backgroundColor,
78
+ textColor: controls.titleColor,
79
+ headingColor: controls.titleColor,
80
+ accentColor: controls.titleColor,
81
+ quoteColor: controls.titleColor,
82
+ captionColor: controls.titleColor,
83
+ watermarkColor: controls.titleColor,
84
+ cardBg: controls.backgroundColor
85
+ },
86
+ typography: { headingFont: ARTICLE_COVER_FONT.family, bodyFont: ARTICLE_COVER_FONT.family },
87
+ footerText: "",
88
+ watermarkText: "",
89
+ chrome: { showHeader: false, showWatermark: false, showPageNumber: false, showFooter: false, showFooterDivider: false, showFooterText: false, showFillRatio: false },
90
+ coverTheme: {
91
+ id: input.id,
92
+ format: "wechat-cover-split",
93
+ typography: { title: { ...font }, subtitle: { ...font }, footer: { ...font } },
94
+ colors: { text: controls.titleColor, accent: controls.titleColor, muted: controls.titleColor },
95
+ background: { fill: { type: "solid", color: controls.backgroundColor }, image: null, overlay: null },
96
+ decoration: { pattern: "none", color: controls.titleColor, opacity: 0, size: 1, image: null },
97
+ layout: { textAlign: controls.textAlign, verticalAlign: "center", safeArea: { ...safeArea } }
98
+ },
99
+ images: [{
100
+ id: ARTICLE_COVER_IMAGE_KEY,
101
+ templateAssetKey: ARTICLE_COVER_IMAGE_KEY,
102
+ src: `asset:${input.assetId}`,
103
+ alt: "文章封面方图",
104
+ fit: "cover",
105
+ isAbsolute: true,
106
+ x: 2350 - controls.safePadding,
107
+ y: -controls.safePadding,
108
+ width: 1000,
109
+ height: 1000,
110
+ borderRadius: 0,
111
+ captionHeight: 0,
112
+ pageIndex: 1,
113
+ zIndex: 20
114
+ }]
115
+ };
116
+ }
117
+ export {
118
+ validateArticleCoverControls,
119
+ normalizeArticleCoverTitle,
120
+ createArticleCoverConfig,
121
+ DEFAULT_ARTICLE_COVER_CONTROLS,
122
+ ARTICLE_COVER_IMAGE_KEY,
123
+ ARTICLE_COVER_FONT
124
+ };
@@ -0,0 +1 @@
1
+ import{useId as w}from"react";import{jsx as r,jsxs as m}from"react/jsx-runtime";function z({value:p,onChange:k,disabled:q=!1}){let f=w();return m("fieldset",{className:"typeset-cover-controls",disabled:q,children:[r("legend",{children:"文章封面"}),r("label",{htmlFor:`${f}-background`,children:"背景色"}),r("input",{id:`${f}-background`,type:"color",value:p.backgroundColor,onChange:(y)=>k({...p,backgroundColor:y.target.value})}),r("label",{htmlFor:`${f}-title`,children:"标题色"}),r("input",{id:`${f}-title`,type:"color",value:p.titleColor,onChange:(y)=>k({...p,titleColor:y.target.value})}),r("label",{htmlFor:`${f}-size`,children:"标题字号"}),r("input",{id:`${f}-size`,type:"number",min:80,max:200,step:1,value:p.fontSize,onChange:(y)=>k({...p,fontSize:Number(y.target.value)})}),r("label",{htmlFor:`${f}-align`,children:"水平对齐"}),m("select",{id:`${f}-align`,value:p.textAlign,onChange:(y)=>k({...p,textAlign:y.target.value}),children:[r("option",{value:"left",children:"左对齐"}),r("option",{value:"center",children:"居中"}),r("option",{value:"right",children:"右对齐"})]}),r("label",{htmlFor:`${f}-padding`,children:"安全留白"}),r("input",{id:`${f}-padding`,type:"number",min:60,max:200,step:1,value:p.safePadding,onChange:(y)=>k({...p,safePadding:Number(y.target.value)})}),r("p",{children:"画布 3350×1000:左侧 2350 为标题,右侧 1000 为中心裁切方图。字体固定,预览以服务端 PNG 为准。"})]})}export{z as CoverControls};
package/dist/index.html CHANGED
@@ -11,7 +11,7 @@
11
11
  rel="stylesheet"
12
12
  />
13
13
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-web/style.css" />
14
- <script type="module" crossorigin src="/assets/index-Dn5YtK3W.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-CrJAVx3W.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-JCv_QmUn.css">
16
16
  </head>
17
17
  <body class="bg-[#fafafa] dark:bg-[#000000] text-[#171717] dark:text-[#ededed] antialiased selection:bg-[#eaeaea] selection:text-[#000000] dark:selection:bg-[#333333] dark:selection:text-[#ffffff] overflow-hidden">
@@ -2,7 +2,8 @@ import {
2
2
  adapter_default,
3
3
  plugin,
4
4
  typesetStudioAdapter
5
- } from "./index-y2tk7642.js";
5
+ } from "./index-51d5rpf7.js";
6
+ import"./index-nwch18wp.js";
6
7
  import"./index-37x76zdn.js";
7
8
  export {
8
9
  typesetStudioAdapter,
@@ -5,11 +5,12 @@ import {
5
5
  renderTypesetResultToPngs,
6
6
  resolveCanvasFont,
7
7
  resolveCanvasFontFamily
8
- } from "./index-697fkv5y.js";
8
+ } from "./index-dz9pmgbk.js";
9
9
  import {
10
10
  registerServerFonts,
11
11
  scanLocalFonts
12
- } from "./index-6c43c7h4.js";
12
+ } from "./index-2vgqm8ms.js";
13
+ import"./index-xg6pyfw6.js";
13
14
  import"./index-37x76zdn.js";
14
15
  export {
15
16
  scanLocalFonts,
@@ -2,7 +2,8 @@ import {
2
2
  readFontFamilyName,
3
3
  registerServerFonts,
4
4
  scanLocalFonts
5
- } from "./index-6c43c7h4.js";
5
+ } from "./index-2vgqm8ms.js";
6
+ import"./index-xg6pyfw6.js";
6
7
  import"./index-37x76zdn.js";
7
8
  export {
8
9
  scanLocalFonts,
@@ -0,0 +1,167 @@
1
+ import {
2
+ clearCache
3
+ } from "./index-xg6pyfw6.js";
4
+
5
+ // src/core/font-loader.ts
6
+ import { createRequire } from "node:module";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { basename, extname, join, resolve } from "node:path";
9
+ function readFontFamilyName(filePath) {
10
+ try {
11
+ const buf = readFileSync(filePath);
12
+ if (buf.length < 12)
13
+ return null;
14
+ const numTables = buf.readUInt16BE(4);
15
+ let nameTableOffset = 0;
16
+ for (let i = 0;i < numTables; i++) {
17
+ const offset = 12 + i * 16;
18
+ if (offset + 16 > buf.length)
19
+ break;
20
+ const tag = buf.toString("utf8", offset, offset + 4);
21
+ if (tag === "name") {
22
+ nameTableOffset = buf.readUInt32BE(offset + 8);
23
+ break;
24
+ }
25
+ }
26
+ if (!nameTableOffset || nameTableOffset + 6 > buf.length)
27
+ return null;
28
+ const count = buf.readUInt16BE(nameTableOffset + 2);
29
+ const stringOffset = nameTableOffset + buf.readUInt16BE(nameTableOffset + 4);
30
+ let familyName = null;
31
+ let preferredFamily = null;
32
+ for (let i = 0;i < count; i++) {
33
+ const recOffset = nameTableOffset + 6 + i * 12;
34
+ if (recOffset + 12 > buf.length)
35
+ break;
36
+ const platformId = buf.readUInt16BE(recOffset);
37
+ const nameId = buf.readUInt16BE(recOffset + 6);
38
+ const length = buf.readUInt16BE(recOffset + 8);
39
+ const strOffset = stringOffset + buf.readUInt16BE(recOffset + 10);
40
+ if (strOffset + length > buf.length)
41
+ continue;
42
+ if (nameId === 1 || nameId === 16) {
43
+ let str = "";
44
+ if (platformId === 0 || platformId === 3) {
45
+ if (length % 2 !== 0)
46
+ continue;
47
+ str = Buffer.from(buf.subarray(strOffset, strOffset + length)).swap16().toString("utf16le").trim();
48
+ } else {
49
+ str = buf.toString("utf8", strOffset, strOffset + length).trim();
50
+ }
51
+ str = str.replace(/\0/g, "").trim();
52
+ if (str) {
53
+ if (nameId === 16)
54
+ preferredFamily = str;
55
+ if (nameId === 1 && !familyName)
56
+ familyName = str;
57
+ }
58
+ }
59
+ }
60
+ return preferredFamily || familyName || null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ var SUPPORTED_EXTS = new Set([".ttf", ".otf", ".woff2", ".woff"]);
66
+ var FORMAT_MAP = {
67
+ ".ttf": "truetype",
68
+ ".otf": "opentype",
69
+ ".woff2": "woff2",
70
+ ".woff": "woff"
71
+ };
72
+ var cachedFonts = null;
73
+ var registeredServerFonts = false;
74
+ function scanLocalFonts(options) {
75
+ if (cachedFonts && !options?.refresh) {
76
+ return cachedFonts;
77
+ }
78
+ const cwd = options?.cwd || process.cwd();
79
+ const searchDirs = [
80
+ join(cwd, "public", "fonts"),
81
+ join(cwd, "dist", "fonts"),
82
+ join(cwd, "fonts")
83
+ ];
84
+ const foundMap = new Map;
85
+ const seenFilenames = new Set;
86
+ for (const dir of searchDirs) {
87
+ if (!existsSync(dir))
88
+ continue;
89
+ let manifest = {};
90
+ const manifestPath = join(dir, "fonts.json");
91
+ if (existsSync(manifestPath)) {
92
+ try {
93
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
94
+ } catch (err) {
95
+ console.warn(`[font-loader] Failed to parse manifest ${manifestPath}:`, err);
96
+ }
97
+ }
98
+ let files = [];
99
+ try {
100
+ files = readdirSync(dir);
101
+ } catch {
102
+ continue;
103
+ }
104
+ for (const filename of files) {
105
+ if (seenFilenames.has(filename))
106
+ continue;
107
+ const ext = extname(filename).toLowerCase();
108
+ if (!SUPPORTED_EXTS.has(ext))
109
+ continue;
110
+ const fullPath = resolve(dir, filename);
111
+ let fileStat;
112
+ try {
113
+ fileStat = statSync(fullPath);
114
+ if (!fileStat.isFile())
115
+ continue;
116
+ } catch {
117
+ continue;
118
+ }
119
+ seenFilenames.add(filename);
120
+ const rawBase = basename(filename, ext);
121
+ const customMeta = manifest[filename] || manifest[rawBase] || {};
122
+ const parsedFamily = ext === ".ttf" || ext === ".otf" ? readFontFamilyName(fullPath) : null;
123
+ const family = customMeta.family || parsedFamily || rawBase;
124
+ const label = customMeta.label || `${family} (本地字体)`;
125
+ if (!foundMap.has(family)) {
126
+ foundMap.set(family, {
127
+ family,
128
+ label,
129
+ filename,
130
+ path: fullPath,
131
+ url: `/fonts/${encodeURIComponent(filename)}`,
132
+ format: FORMAT_MAP[ext] || "truetype",
133
+ fileSizeBytes: fileStat.size
134
+ });
135
+ }
136
+ }
137
+ }
138
+ cachedFonts = Array.from(foundMap.values());
139
+ return cachedFonts;
140
+ }
141
+ function registerServerFonts(options) {
142
+ const fonts = scanLocalFonts({ refresh: options?.refresh });
143
+ if (registeredServerFonts && !options?.refresh) {
144
+ return fonts;
145
+ }
146
+ try {
147
+ const { GlobalFonts } = createRequire(import.meta.url)("@napi-rs/canvas");
148
+ if (GlobalFonts && typeof GlobalFonts.registerFromPath === "function") {
149
+ for (const font of fonts) {
150
+ try {
151
+ GlobalFonts.registerFromPath(font.path, font.family);
152
+ const rawBase = basename(font.filename, extname(font.filename));
153
+ if (rawBase !== font.family) {
154
+ GlobalFonts.registerFromPath(font.path, rawBase);
155
+ }
156
+ } catch (err) {
157
+ console.warn(`[font-loader] Failed to register font ${font.filename} in Skia:`, err);
158
+ }
159
+ }
160
+ registeredServerFonts = true;
161
+ clearCache();
162
+ }
163
+ } catch {}
164
+ return fonts;
165
+ }
166
+
167
+ export { readFontFamilyName, scanLocalFonts, registerServerFonts };
@@ -1,3 +1,9 @@
1
+ import {
2
+ registerArticleCoverFont
3
+ } from "./index-94yttj49.js";
4
+ import {
5
+ ARTICLE_COVER_FONT
6
+ } from "./index-nwch18wp.js";
1
7
  import {
2
8
  DEFAULT_BORDER,
3
9
  DEFAULT_PATTERN,
@@ -7,14 +13,14 @@ import {
7
13
  renderTypesetResultToPngs,
8
14
  resolveDataDir,
9
15
  validateCanvasStyle
10
- } from "./index-697fkv5y.js";
16
+ } from "./index-dz9pmgbk.js";
11
17
  import {
12
18
  buildLineTextFromRange,
13
19
  getLineTextCache,
14
20
  measureNaturalWidth,
15
21
  prepareWithSegments,
16
22
  stepPreparedLineGeometry
17
- } from "./index-6c43c7h4.js";
23
+ } from "./index-xg6pyfw6.js";
18
24
  import {
19
25
  __require
20
26
  } from "./index-37x76zdn.js";
@@ -3341,6 +3347,12 @@ function resolveTypesetResult(body) {
3341
3347
  chrome: { ...baseConfig.chrome, ...overrides?.chrome }
3342
3348
  };
3343
3349
  const resolvedImages = applyTemplateImages(body.images || [], effectiveConfig.images, body.templateImageOverrides);
3350
+ if (effectiveConfig.typography.headingFont === ARTICLE_COVER_FONT.family || effectiveConfig.typography.bodyFont === ARTICLE_COVER_FONT.family) {
3351
+ const path = process.env.TYPESET_ARTICLE_COVER_FONT_PATH;
3352
+ if (!path)
3353
+ throw new Error("Article cover font path is not configured for this renderer");
3354
+ registerArticleCoverFont({ path });
3355
+ }
3344
3356
  let result;
3345
3357
  while (true) {
3346
3358
  try {
@@ -1,3 +1,6 @@
1
+ import {
2
+ normalizeArticleCoverTitle
3
+ } from "./index-nwch18wp.js";
1
4
  import {
2
5
  __require
3
6
  } from "./index-37x76zdn.js";
@@ -103,8 +106,8 @@ async function renderRemoteBatch(studioUrl, body, outputDir, route, kind) {
103
106
  }
104
107
  async function renderLocalBatch(body, outputDir, route, kind) {
105
108
  const [{ resolveTypesetResult }, { renderTypesetResultToPngs }] = await Promise.all([
106
- import("./render-064weavx.js"),
107
- import("./canvas-renderer-wce6xgb2.js")
109
+ import("./render-epbcmzxe.js"),
110
+ import("./canvas-renderer-hpe2bqkt.js")
108
111
  ]);
109
112
  const { result } = resolveTypesetResult(body);
110
113
  const pngs = await renderTypesetResultToPngs(result);
@@ -131,7 +134,7 @@ async function renderLocalBatch(body, outputDir, route, kind) {
131
134
  }
132
135
  var typesetStudioAdapter = {
133
136
  name: "typeset-studio",
134
- version: "0.2.8",
137
+ version: "0.3.1",
135
138
  async doctor() {
136
139
  const checks = [];
137
140
  const REMOTE_STUDIO_URL = remoteStudioUrl();
@@ -175,7 +178,7 @@ var typesetStudioAdapter = {
175
178
  });
176
179
  }
177
180
  try {
178
- const { scanLocalFonts } = await import("./font-loader-j28mt43t.js");
181
+ const { scanLocalFonts } = await import("./font-loader-cft81crk.js");
179
182
  const localFonts = scanLocalFonts();
180
183
  checks.push({
181
184
  name: "local-fonts",
@@ -213,7 +216,10 @@ var typesetStudioAdapter = {
213
216
  const fallbackIcon = visual.fallbackIcon ?? stateVisual?.fallback_icon;
214
217
  const template = ["wechat-cover-split", "poster-3-4", "longform-3-4"].includes(input.template || "") ? undefined : input.template;
215
218
  input.onProgress?.({ stage: "typeset", message: "开始排版", route, account });
216
- const title = cleanTitle(input.title || state.metadata?.title || "");
219
+ const rawTitle = input.title || state.metadata?.title || "";
220
+ const title = process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT === "1" ? normalizeArticleCoverTitle(rawTitle) : cleanTitle(rawTitle);
221
+ if (process.env.ZZHUB_PIPELINE_COVER_SNAPSHOT === "1" && !title)
222
+ throw new RangeError("Article cover title is empty after normalization");
217
223
  const common = {
218
224
  account,
219
225
  title,
@@ -0,0 +1,34 @@
1
+ import {
2
+ ARTICLE_COVER_FONT
3
+ } from "./index-nwch18wp.js";
4
+ import {
5
+ clearCache
6
+ } from "./index-xg6pyfw6.js";
7
+
8
+ // src/server-font.ts
9
+ import { createHash } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
11
+ import { isAbsolute } from "node:path";
12
+ import { GlobalFonts } from "@napi-rs/canvas";
13
+ var registered = false;
14
+ function registerArticleCoverFont({ path }) {
15
+ if (typeof path !== "string" || !isAbsolute(path))
16
+ throw new Error("Article cover font requires a trusted absolute path");
17
+ let bytes;
18
+ try {
19
+ bytes = readFileSync(path);
20
+ } catch {
21
+ throw new Error("Article cover font file is unavailable");
22
+ }
23
+ if (createHash("sha256").update(bytes).digest("hex") !== ARTICLE_COVER_FONT.sha256)
24
+ throw new Error("Article cover font hash mismatch");
25
+ if (!registered || !GlobalFonts.has(ARTICLE_COVER_FONT.family)) {
26
+ if (!GlobalFonts.registerFromPath(path, ARTICLE_COVER_FONT.family) || !GlobalFonts.has(ARTICLE_COVER_FONT.family))
27
+ throw new Error("Article cover font registration failed");
28
+ registered = true;
29
+ clearCache();
30
+ }
31
+ return ARTICLE_COVER_FONT;
32
+ }
33
+
34
+ export { registerArticleCoverFont };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  registerServerFonts
3
- } from "./index-6c43c7h4.js";
3
+ } from "./index-2vgqm8ms.js";
4
4
 
5
5
  // src/core/canvas-renderer.ts
6
6
  import { createCanvas, loadImage } from "@napi-rs/canvas";
@@ -509,7 +509,17 @@ async function drawPageToContext(ctx, options) {
509
509
  ctx.roundRect(-w / 2, -h / 2, w, h, radius);
510
510
  ctx.clip();
511
511
  if (loadedImg) {
512
- ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
512
+ if (img.fit === "cover") {
513
+ const sourceWidth = loadedImg.naturalWidth || loadedImg.width;
514
+ const sourceHeight = loadedImg.naturalHeight || loadedImg.height;
515
+ if (!(sourceWidth > 0 && sourceHeight > 0))
516
+ throw new Error("Invalid cover image dimensions");
517
+ const scale = Math.max(w / sourceWidth, h / sourceHeight);
518
+ const cropWidth = w / scale;
519
+ const cropHeight = h / scale;
520
+ ctx.drawImage(loadedImg, (sourceWidth - cropWidth) / 2, (sourceHeight - cropHeight) / 2, cropWidth, cropHeight, -w / 2, -h / 2, w, h);
521
+ } else
522
+ ctx.drawImage(loadedImg, -w / 2, -h / 2, w, h);
513
523
  } else {
514
524
  ctx.fillStyle = theme.cardBg || "#222222";
515
525
  ctx.fillRect(-w / 2, -h / 2, w, h);
@@ -0,0 +1,18 @@
1
+ // src/core/article-cover.ts
2
+ var ARTICLE_COVER_FONT = Object.freeze({
3
+ id: "publishing-noto-cjk-regular-v1",
4
+ sha256: "b76b0433203017ca80401b2ee0dd69350349871c4b19d504c34dbdd80541690a",
5
+ family: "Publishing Cover"
6
+ });
7
+ var DEFAULT_ARTICLE_COVER_CONTROLS = Object.freeze({
8
+ backgroundColor: "#ffffff",
9
+ titleColor: "#171717",
10
+ fontSize: 120,
11
+ textAlign: "left",
12
+ safePadding: 120
13
+ });
14
+ function normalizeArticleCoverTitle(raw) {
15
+ return raw.trim().replace(/^(?:#+\s*)+/, "").trim();
16
+ }
17
+
18
+ export { ARTICLE_COVER_FONT, normalizeArticleCoverTitle };
@@ -3715,166 +3715,4 @@ function clearCache() {
3715
3715
  clearMeasurementCaches();
3716
3716
  }
3717
3717
 
3718
- // src/core/font-loader.ts
3719
- import { createRequire } from "node:module";
3720
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3721
- import { basename, extname, join, resolve } from "node:path";
3722
- function readFontFamilyName(filePath) {
3723
- try {
3724
- const buf = readFileSync(filePath);
3725
- if (buf.length < 12)
3726
- return null;
3727
- const numTables = buf.readUInt16BE(4);
3728
- let nameTableOffset = 0;
3729
- for (let i = 0;i < numTables; i++) {
3730
- const offset = 12 + i * 16;
3731
- if (offset + 16 > buf.length)
3732
- break;
3733
- const tag = buf.toString("utf8", offset, offset + 4);
3734
- if (tag === "name") {
3735
- nameTableOffset = buf.readUInt32BE(offset + 8);
3736
- break;
3737
- }
3738
- }
3739
- if (!nameTableOffset || nameTableOffset + 6 > buf.length)
3740
- return null;
3741
- const count = buf.readUInt16BE(nameTableOffset + 2);
3742
- const stringOffset = nameTableOffset + buf.readUInt16BE(nameTableOffset + 4);
3743
- let familyName = null;
3744
- let preferredFamily = null;
3745
- for (let i = 0;i < count; i++) {
3746
- const recOffset = nameTableOffset + 6 + i * 12;
3747
- if (recOffset + 12 > buf.length)
3748
- break;
3749
- const platformId = buf.readUInt16BE(recOffset);
3750
- const nameId = buf.readUInt16BE(recOffset + 6);
3751
- const length = buf.readUInt16BE(recOffset + 8);
3752
- const strOffset = stringOffset + buf.readUInt16BE(recOffset + 10);
3753
- if (strOffset + length > buf.length)
3754
- continue;
3755
- if (nameId === 1 || nameId === 16) {
3756
- let str = "";
3757
- if (platformId === 0 || platformId === 3) {
3758
- if (length % 2 !== 0)
3759
- continue;
3760
- str = Buffer.from(buf.subarray(strOffset, strOffset + length)).swap16().toString("utf16le").trim();
3761
- } else {
3762
- str = buf.toString("utf8", strOffset, strOffset + length).trim();
3763
- }
3764
- str = str.replace(/\0/g, "").trim();
3765
- if (str) {
3766
- if (nameId === 16)
3767
- preferredFamily = str;
3768
- if (nameId === 1 && !familyName)
3769
- familyName = str;
3770
- }
3771
- }
3772
- }
3773
- return preferredFamily || familyName || null;
3774
- } catch {
3775
- return null;
3776
- }
3777
- }
3778
- var SUPPORTED_EXTS = new Set([".ttf", ".otf", ".woff2", ".woff"]);
3779
- var FORMAT_MAP = {
3780
- ".ttf": "truetype",
3781
- ".otf": "opentype",
3782
- ".woff2": "woff2",
3783
- ".woff": "woff"
3784
- };
3785
- var cachedFonts = null;
3786
- var registeredServerFonts = false;
3787
- function scanLocalFonts(options) {
3788
- if (cachedFonts && !options?.refresh) {
3789
- return cachedFonts;
3790
- }
3791
- const cwd = options?.cwd || process.cwd();
3792
- const searchDirs = [
3793
- join(cwd, "public", "fonts"),
3794
- join(cwd, "dist", "fonts"),
3795
- join(cwd, "fonts")
3796
- ];
3797
- const foundMap = new Map;
3798
- const seenFilenames = new Set;
3799
- for (const dir of searchDirs) {
3800
- if (!existsSync(dir))
3801
- continue;
3802
- let manifest = {};
3803
- const manifestPath = join(dir, "fonts.json");
3804
- if (existsSync(manifestPath)) {
3805
- try {
3806
- manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
3807
- } catch (err) {
3808
- console.warn(`[font-loader] Failed to parse manifest ${manifestPath}:`, err);
3809
- }
3810
- }
3811
- let files = [];
3812
- try {
3813
- files = readdirSync(dir);
3814
- } catch {
3815
- continue;
3816
- }
3817
- for (const filename of files) {
3818
- if (seenFilenames.has(filename))
3819
- continue;
3820
- const ext = extname(filename).toLowerCase();
3821
- if (!SUPPORTED_EXTS.has(ext))
3822
- continue;
3823
- const fullPath = resolve(dir, filename);
3824
- let fileStat;
3825
- try {
3826
- fileStat = statSync(fullPath);
3827
- if (!fileStat.isFile())
3828
- continue;
3829
- } catch {
3830
- continue;
3831
- }
3832
- seenFilenames.add(filename);
3833
- const rawBase = basename(filename, ext);
3834
- const customMeta = manifest[filename] || manifest[rawBase] || {};
3835
- const parsedFamily = ext === ".ttf" || ext === ".otf" ? readFontFamilyName(fullPath) : null;
3836
- const family = customMeta.family || parsedFamily || rawBase;
3837
- const label = customMeta.label || `${family} (本地字体)`;
3838
- if (!foundMap.has(family)) {
3839
- foundMap.set(family, {
3840
- family,
3841
- label,
3842
- filename,
3843
- path: fullPath,
3844
- url: `/fonts/${encodeURIComponent(filename)}`,
3845
- format: FORMAT_MAP[ext] || "truetype",
3846
- fileSizeBytes: fileStat.size
3847
- });
3848
- }
3849
- }
3850
- }
3851
- cachedFonts = Array.from(foundMap.values());
3852
- return cachedFonts;
3853
- }
3854
- function registerServerFonts(options) {
3855
- const fonts = scanLocalFonts({ refresh: options?.refresh });
3856
- if (registeredServerFonts && !options?.refresh) {
3857
- return fonts;
3858
- }
3859
- try {
3860
- const { GlobalFonts } = createRequire(import.meta.url)("@napi-rs/canvas");
3861
- if (GlobalFonts && typeof GlobalFonts.registerFromPath === "function") {
3862
- for (const font of fonts) {
3863
- try {
3864
- GlobalFonts.registerFromPath(font.path, font.family);
3865
- const rawBase = basename(font.filename, extname(font.filename));
3866
- if (rawBase !== font.family) {
3867
- GlobalFonts.registerFromPath(font.path, rawBase);
3868
- }
3869
- } catch (err) {
3870
- console.warn(`[font-loader] Failed to register font ${font.filename} in Skia:`, err);
3871
- }
3872
- }
3873
- registeredServerFonts = true;
3874
- clearCache();
3875
- }
3876
- } catch {}
3877
- return fonts;
3878
- }
3879
-
3880
- export { stepPreparedLineGeometry, getLineTextCache, buildLineTextFromRange, prepareWithSegments, measureNaturalWidth, readFontFamilyName, scanLocalFonts, registerServerFonts };
3718
+ export { stepPreparedLineGeometry, getLineTextCache, buildLineTextFromRange, prepareWithSegments, measureNaturalWidth, clearCache };