1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-08-12 09:20:31 +00:00

feat:增加UAF导出字体字重与大小自定义功能

This commit is contained in:
2-2-3-trimethylpentane 2026-07-12 12:20:05 +08:00
parent 64b15d058a
commit 69ff28e39d
8 changed files with 531 additions and 140 deletions

Binary file not shown.

Binary file not shown.

View File

@ -33,7 +33,9 @@
size="large"
rounded="xl"
><v-icon icon="mdi-swap-vertical-bold"></v-icon></v-btn>
>
<v-icon icon="mdi-swap-vertical-bold" />
</v-btn>
</template>
<v-list density="comfortable">
<v-list-item
@ -76,9 +78,9 @@
v-if="showExamScheduleButton"
prepend-icon="mdi-calendar-check"
size="large"
@click="$emit('add-exam-card')"
class="ml-2"
color="green"
@click="$emit('add-exam-card')"
>
考试看板
</v-btn>
@ -105,7 +107,6 @@
>
添加测试卡片
</v-btn>
</div>
<v-card

View File

@ -24,6 +24,25 @@
<v-card-text class="transfer-content">
<template v-if="mode === 'export'">
<v-tabs
v-if="mobile"
v-model="mobileTab"
fixed-tabs
class="mb-3"
>
<v-tab value="edit">
编辑
</v-tab>
<v-tab value="preview">
预览
</v-tab>
</v-tabs>
<div
class="export-layout"
:class="{ mobile: mobile, 'show-preview': mobile && mobileTab === 'preview' }"
>
<div class="editor-section">
<v-text-field
v-model="exportDate"
class="mb-3"
@ -74,6 +93,7 @@
<v-list-item
v-for="row in previewRows"
:key="row.id"
class="edit-list-item"
>
<template #prepend>
<v-checkbox-btn
@ -81,6 +101,7 @@
:disabled="row.issues.length > 0"
/>
</template>
<div class="d-flex flex-column w-100">
<v-list-item-title>{{ row.assignment.subject }}</v-list-item-title>
<v-list-item-subtitle class="content-preview">
{{ row.assignment.content }}
@ -108,6 +129,7 @@
{{ row.issues.join("") }}
</span>
</div>
</div>
</v-list-item>
</v-list>
@ -117,6 +139,95 @@
text="该日期没有可导出的作业"
title="暂无作业"
/>
</div>
<div class="preview-section">
<div class="preview-placeholder">
<div class="preview-scroll-container">
<v-progress-linear
v-if="previewLoading"
indeterminate
/>
<v-alert
v-else-if="previewError"
type="error"
variant="tonal"
>
{{ previewError }}
</v-alert>
<v-alert
v-else-if="!previewUrl"
type="info"
variant="tonal"
>
请选择有效作业以预览
</v-alert>
<iframe
v-else
:src="previewUrl"
class="pdf-preview-frame"
title="UAF 导出预览"
/>
</div>
</div>
<v-expansion-panels
v-model="fontSettingsPanels"
variant="accordion"
class="mt-3 font-settings-panels"
>
<v-expansion-panel value="settings">
<v-expansion-panel-title>
导出字体设置
</v-expansion-panel-title>
<v-expansion-panel-text>
<div
v-for="field in styleFields"
:key="field.key"
class="mb-3"
>
<div class="d-flex align-center ga-3">
<span class="text-caption label-min-width">{{ field.label }}</span>
<v-slider
v-model="globalStyle[field.key].fontSize"
:min="field.min"
:max="field.max"
:step="0.5"
hide-details
class="flex-grow-1"
/>
<v-text-field
:model-value="globalStyle[field.key].fontSize"
type="number"
:min="field.min"
:max="field.max"
step="0.5"
density="compact"
hide-details
variant="outlined"
class="style-number-field"
@update:model-value="setGlobalFontSize(field.key, $event)"
/>
</div>
<div class="d-flex align-center ga-3 mt-2">
<span class="text-caption label-min-width">字重</span>
<v-select
v-model="globalStyle[field.key].fontWeight"
:items="fontWeightOptions"
item-title="title"
item-value="value"
density="compact"
hide-details
variant="outlined"
class="style-weight-select"
/>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</template>
<template v-else>
@ -247,10 +358,12 @@
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { computed, onUnmounted, ref, watch } from "vue";
import { useDisplay } from "vuetify";
import dataProvider from "@/utils/dataProvider";
import { debounce } from "@/utils/debounce";
import {
buildUafPdfBytes,
createExportPreview,
createImportPlan,
downloadUafAssignments,
@ -287,6 +400,22 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "success", "error", "imported"]);
const { mobile } = useDisplay();
const fontWeightOptions = [
{ title: "Light", value: "light" },
{ title: "Regular", value: "regular" },
{ title: "Bold", value: "bold" },
];
const styleFields = [
{ key: "subject", label: "标题", min: 10, max: 32 },
{ key: "content", label: "正文", min: 8, max: 24 },
{ key: "tags", label: "标签", min: 6, max: 16 },
];
const globalStyle = ref({
subject: { fontSize: 17, fontWeight: "regular" },
content: { fontSize: 13.5, fontWeight: "regular" },
tags: { fontSize: 9.5, fontWeight: "regular" },
});
const fontSettingsPanels = ref([]);
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
@ -301,11 +430,21 @@ const importPlan = ref(null);
const importError = ref("");
const loadingImport = ref(false);
const importing = ref(false);
const mobileTab = ref("edit");
const previewUrl = ref("");
const previewLoading = ref(false);
const previewError = ref("");
const busy = computed(() => exporting.value || importing.value || loadingImport.value);
const selectedAssignments = computed(() =>
previewRows.value.filter((row) => row.selected).map((row) => row.assignment),
);
const styledSelectedAssignments = computed(() =>
selectedAssignments.value.map((assignment) => ({
...assignment,
style: JSON.parse(JSON.stringify(globalStyle.value)),
})),
);
const validRows = computed(() => previewRows.value.filter((row) => row.issues.length === 0));
const allValidSelected = computed(
() => validRows.value.length > 0 && validRows.value.every((row) => row.selected),
@ -335,7 +474,11 @@ const footerText = computed(() => {
watch(
() => props.modelValue,
(open) => {
(open, oldOpen) => {
if (!open && oldOpen) {
releasePreviewUrl();
mobileTab.value = "edit";
}
if (!open) return;
if (props.mode === "export") {
exportDate.value = displayDate(props.currentDate);
@ -348,6 +491,20 @@ watch(
},
);
watch(
selectedAssignments,
() => {
generatePreview();
},
{ deep: true },
);
watch(globalStyle, () => generatePreview(), { deep: true });
onUnmounted(() => {
releasePreviewUrl();
});
function cloneBoard(board) {
return JSON.parse(JSON.stringify(board || { homework: {}, attendance: {} }));
}
@ -387,10 +544,47 @@ function toggleAll() {
for (const row of validRows.value) row.selected = selected;
}
function setGlobalFontSize(fieldKey, value) {
const field = styleFields.find((f) => f.key === fieldKey);
const parsed = Number(value);
const size = Number.isFinite(parsed) ? parsed : field.min;
globalStyle.value[fieldKey].fontSize =
Math.round(Math.min(field.max, Math.max(field.min, size)) * 2) / 2;
}
function releasePreviewUrl() {
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
}
const generatePreview = debounce(async () => {
if (!styledSelectedAssignments.value.length) {
releasePreviewUrl();
previewError.value = "";
previewLoading.value = false;
return;
}
previewLoading.value = true;
previewError.value = "";
try {
const pdfBytes = await buildUafPdfBytes(styledSelectedAssignments.value);
const blob = new window.Blob([pdfBytes], { type: "application/pdf" });
releasePreviewUrl();
previewUrl.value = URL.createObjectURL(blob);
} catch (error) {
releasePreviewUrl();
previewError.value = formatError(error);
} finally {
previewLoading.value = false;
}
}, 800);
async function exportSelected() {
exporting.value = true;
try {
const filename = await downloadUafAssignments(selectedAssignments.value, exportDate.value);
const filename = await downloadUafAssignments(styledSelectedAssignments.value, exportDate.value);
emit("success", "导出成功", filename);
dialog.value = false;
} catch (error) {
@ -481,6 +675,91 @@ function displayDate(date) {
-webkit-line-clamp: 2;
}
.export-layout {
display: flex;
gap: 16px;
height: 100%;
min-height: 360px;
}
.editor-section {
flex: 7;
min-width: 0;
overflow-y: auto;
}
.preview-section {
flex: 5;
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.preview-placeholder {
flex: 1 1 auto;
min-height: 360px;
max-height: 480px;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-scroll-container {
flex: 1;
overflow: auto;
position: relative;
}
.pdf-preview-frame {
width: 100%;
height: 100%;
border: none;
}
.edit-list-item {
align-items: flex-start;
}
.style-weight-select {
max-width: 110px;
}
.label-min-width {
min-width: 40px;
text-align: right;
}
.style-number-field {
max-width: 90px;
}
.style-select-field {
max-width: 110px;
}
.export-layout.mobile {
flex-direction: column;
max-height: none;
}
.export-layout.mobile .editor-section,
.export-layout.mobile .preview-section {
display: none;
overflow-y: visible;
min-height: 60vh;
}
.export-layout.mobile.show-preview .preview-section {
display: block;
}
.export-layout.mobile:not(.show-preview) .editor-section {
display: block;
}
.action-select {
min-width: 132px;
width: 132px;

View File

@ -490,7 +490,16 @@
考试看板
</v-card-title>
<v-card-text>
<v-list><v-list-item active color="green" @click="$router.push('/examschedule')" append-icon="mdi-arrow-right">打开考试看板</v-list-item></v-list>
<v-list>
<v-list-item
active
color="green"
append-icon="mdi-arrow-right"
@click="$router.push('/examschedule')"
>
打开考试看板
</v-list-item>
</v-list>
<v-list v-if="examStore.examList.length > 0">
<v-list-item
v-for="exam in examStore.examList"

View File

@ -71,6 +71,7 @@ export function createUafDocument(items, dateValue) {
date,
content: item.content,
tags: normalizeTags(item.tags),
style: item.style ? JSON.parse(JSON.stringify(item.style)) : JSON.parse(JSON.stringify(DEFAULT_ASSIGNMENT_STYLE)),
}));
if (assignments.length === 0) {
@ -84,6 +85,12 @@ export function createUafDocument(items, dateValue) {
return assignments;
}
export const DEFAULT_ASSIGNMENT_STYLE = {
subject: { fontSize: 17, fontWeight: "regular" },
content: { fontSize: 13.5, fontWeight: "regular" },
tags: { fontSize: 9.5, fontWeight: "regular" },
};
export function createExportPreview(items, dateValue) {
const date = normalizeUafDate(dateValue);
return items
@ -95,9 +102,10 @@ export function createExportPreview(items, dateValue) {
date,
content: item.content,
tags: normalizeTags(item.tags),
style: JSON.parse(JSON.stringify(DEFAULT_ASSIGNMENT_STYLE)),
};
const issues = validateUafAssignment(assignment, assignment.subject || `${index + 1} 张卡片`);
return { id: `${item.key || index}-${index}`, assignment, selected: issues.length === 0, issues };
return { id: `${item.key || index}-${index}`, assignment, selected: issues.length === 0, issues, expanded: false };
});
}
@ -260,19 +268,35 @@ export async function executeImportPlan(plan, saveBoardData) {
return { imported, skipped, savedDates, failedDates };
}
export async function downloadUafAssignments(assignments, dateValue) {
async function fetchFont(base, filename) {
const response = await fetch(new URL(`${base}uaf/${filename}`, window.location.origin));
if (!response.ok) throw new Error(`加载字体 ${filename} 失败:${response.status}`);
return response.arrayBuffer();
}
export async function buildUafPdfBytes(assignments) {
if (!assignments.length) throw new UafExportValidationError(["请至少选择一项作业"]);
const issues = assignments.flatMap((assignment) => validateUafAssignment(assignment));
if (issues.length) throw new UafExportValidationError(issues);
const { createUafPdf } = await loadBrowserUaf();
const base = import.meta.env.BASE_URL || "/";
const fontUrl = new URL(`${base}uaf/HarmonyOS_Sans_SC_Regular.ttf`, window.location.origin);
const [fontBytes, fontLightBytes, fontBoldBytes] = await Promise.all([
fetchFont(base, "HarmonyOS_Sans_SC_Regular.ttf"),
fetchFont(base, "HarmonyOS_Sans_SC_Light.ttf"),
fetchFont(base, "HarmonyOS_Sans_SC_Bold.ttf"),
]);
const wasmUrl = new URL(`${base}uaf/hb-subset.wasm`, window.location.origin);
const pdfBytes = await createUafPdf(assignments, {
fontUrl,
return createUafPdf(assignments, {
fontBytes,
fontLightBytes,
fontBoldBytes,
wasmUrl,
theme: "classworks-dark",
});
}
export async function downloadUafAssignments(assignments, dateValue) {
const pdfBytes = await buildUafPdfBytes(assignments);
const blob = new window.Blob([pdfBytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");

View File

@ -8323,13 +8323,22 @@ function isValidIso8601(value) {
const parsed = Date.parse(value);
return !Number.isNaN(parsed);
}
var stylePartSchema = external_exports.object({
fontSize: external_exports.number().optional(),
fontWeight: external_exports.string().optional()
});
var uafAssignmentSchema = external_exports.object({
subject: external_exports.string().min(1, "subject must not be empty").max(LIMITS.subjectMax, `subject must be at most ${LIMITS.subjectMax} characters`),
date: external_exports.string().min(1, "date must not be empty").refine(isValidIso8601, "date must be valid ISO 8601"),
content: external_exports.string().min(1, "content must not be empty").max(LIMITS.contentMax, `content must be at most ${LIMITS.contentMax} characters`),
tags: external_exports.array(
external_exports.string().min(1, "tag must not be empty").max(LIMITS.tagMax, `each tag must be at most ${LIMITS.tagMax} characters`).refine((t) => !t.includes(";"), 'tag must not contain ";"')
).max(LIMITS.tagCountMax, `at most ${LIMITS.tagCountMax} tags allowed`)
).max(LIMITS.tagCountMax, `at most ${LIMITS.tagCountMax} tags allowed`),
style: external_exports.object({
subject: stylePartSchema.optional(),
content: stylePartSchema.optional(),
tags: stylePartSchema.optional()
}).optional()
});
var uafDocumentSchema = external_exports.array(uafAssignmentSchema).min(1, "document must contain at least one assignment");
function escapeField(value) {
@ -25488,7 +25497,7 @@ var INTRINSICS = {
"%Error%": Error,
"%ErrorPrototype%": Error.prototype,
"%eval%": eval,
// eslint-disable-line no-eval
"%EvalError%": EvalError,
"%EvalErrorPrototype%": EvalError.prototype,
"%Float32Array%": typeof Float32Array === "undefined" ? undefined$1 : Float32Array,
@ -48402,7 +48411,7 @@ var decodePDFRawStream = function(_a) {
for (var idx = 0, len2 = Filter.size(); idx < len2; idx++) {
stream2 = decodeStream(stream2, Filter.lookup(idx, PDFName_default), DecodeParms && DecodeParms.lookupMaybe(idx, PDFDict_default));
}
} else if (!!Filter) {
} else if (Filter) {
throw new UnexpectedObjectTypeError([PDFName_default, PDFArray_default], Filter);
}
return stream2;
@ -50830,7 +50839,7 @@ var PDFParser = (
this.context.header = this.parseHeader();
_a.label = 1;
case 1:
if (!!this.bytes.done()) return [3, 3];
if (this.bytes.done()) return [3, 3];
return [4, this.parseDocumentSection()];
case 2:
_a.sent();
@ -53019,7 +53028,7 @@ var PDFEmbeddedPage = (
return __generator(this, function(_a) {
switch (_a.label) {
case 0:
if (!!this.alreadyEmbedded) return [3, 2];
if (this.alreadyEmbedded) return [3, 2];
return [4, this.embedder.embedIntoContext(this.doc.context, this.ref)];
case 1:
_a.sent();
@ -54745,7 +54754,7 @@ var PDFEmbeddedFile = (
return __generator(this, function(_a) {
switch (_a.label) {
case 0:
if (!!this.alreadyEmbedded) return [3, 2];
if (this.alreadyEmbedded) return [3, 2];
return [4, this.embedder.embedIntoContext(this.doc.context, this.ref)];
case 1:
ref = _a.sent();
@ -54803,7 +54812,7 @@ var PDFJavaScript = (
return __generator(this, function(_b) {
switch (_b.label) {
case 0:
if (!!this.alreadyEmbedded) return [3, 2];
if (this.alreadyEmbedded) return [3, 2];
_a = this.doc, catalog = _a.catalog, context2 = _a.context;
return [4, this.embedder.embedIntoContext(this.doc.context, this.ref)];
case 1:
@ -56455,21 +56464,28 @@ function createFragments(document, font) {
const maxWidth = CARD_WIDTH - CARD_PAD * 2;
const fragments = [];
for (const assignment of document) {
const lines = wrapText(assignment.content, font, CONTENT_FONT, maxWidth);
const contentStyle = assignment.style?.content || {};
const contentFontSize = typeof contentStyle.fontSize === "number" ? contentStyle.fontSize : CONTENT_FONT;
const contentLineHeight = contentFontSize * (CONTENT_LINE_HEIGHT / CONTENT_FONT);
const tagStyle = assignment.style?.tags || {};
const tagFontSize = typeof tagStyle.fontSize === "number" ? tagStyle.fontSize : TAG_FONT;
const tagAreaHeight = Math.max(TAG_AREA_HEIGHT, tagFontSize * 3 + 12);
const lines = wrapText(assignment.content, font, contentFontSize, maxWidth);
for (let index = 0; index < lines.length; index += MAX_LINES_PER_FRAGMENT) {
const fragmentLines = lines.slice(index, index + MAX_LINES_PER_FRAGMENT);
const showTags = index + MAX_LINES_PER_FRAGMENT >= lines.length;
const contentHeight = fragmentLines.length * CONTENT_LINE_HEIGHT;
const contentHeight = fragmentLines.length * contentLineHeight;
const height = Math.max(
MIN_CARD_HEIGHT,
CARD_PAD + HEADER_HEIGHT + 12 + contentHeight + (showTags ? TAG_AREA_HEIGHT : 12) + CARD_PAD
CARD_PAD + HEADER_HEIGHT + 12 + contentHeight + (showTags ? tagAreaHeight : 12) + CARD_PAD
);
fragments.push({
assignment,
continuation: index > 0,
lines: fragmentLines,
showTags,
height
height,
contentLineHeight
});
}
}
@ -56493,40 +56509,67 @@ function drawPageBackground(page, font, canRenderCjk, colors) {
color: colors.watermark
});
}
function drawTags(page, tags, x, y, font, colors) {
function resolveFont(fontWeight, fontNormal, fontLight, fontBold) {
const weight = String(fontWeight || "regular").toLowerCase();
if (weight === "light") return fontLight;
if (weight === "bold") return fontBold;
return fontNormal;
}
function drawTextWithWeight(page, text, options) {
const { font, fontLight, fontBold, fontWeight, opacity, ...rest } = options;
const fontToUse = resolveFont(fontWeight, font, fontLight, fontBold);
page.drawText(text, { ...rest, font: fontToUse, opacity: opacity ?? 1 });
}
function drawTags(page, tags, x, y, font, fontLight, fontBold, fontSize, fontWeight, colors) {
if (tags.length === 0) {
page.drawText("", { x, y, font, size: TAG_FONT });
page.drawText("", { x, y, font, size: fontSize });
return;
}
let cursor = x;
const maxX = x + CARD_WIDTH - CARD_PAD * 2;
const pillHeight = Math.max(19, fontSize + 8);
for (const tag2 of tags) {
const label = ellipsize(tag2, font, TAG_FONT, CARD_WIDTH - CARD_PAD * 2 - 16);
const width = Math.min(widthOf(font, label, TAG_FONT) + 16, CARD_WIDTH - CARD_PAD * 2);
const label = ellipsize(tag2, font, fontSize, CARD_WIDTH - CARD_PAD * 2 - 16);
const width = Math.min(widthOf(font, label, fontSize) + 16, CARD_WIDTH - CARD_PAD * 2);
if (cursor + width > maxX) break;
drawPill(page, cursor, y, width, 19, colors.chip);
page.drawText(label, { x: cursor + 8, y: y + 5.2, size: TAG_FONT, font, color: colors.chipText });
drawPill(page, cursor, y, width, pillHeight, colors.chip);
drawTextWithWeight(page, label, {
x: cursor + 8,
y: y + (pillHeight - fontSize) / 2 - 1,
size: fontSize,
font,
fontLight,
fontBold,
fontWeight,
color: colors.chipText
});
cursor += width + 6;
}
}
function drawFragment(page, fragment, x, top, font, fontBold, dateDisplay, canRenderCjk, colors) {
function drawFragment(page, fragment, x, top, font, fontLight, fontBold, dateDisplay, canRenderCjk, colors) {
const y = top - fragment.height;
drawRoundedRect(page, x, y, CARD_WIDTH, fragment.height, CARD_RADIUS, colors.card, {
color: colors.border,
width: 1
});
const subjectStyle = fragment.assignment.style?.subject || {};
const subjectFontSize = typeof subjectStyle.fontSize === "number" ? subjectStyle.fontSize : SUBJECT_FONT;
const subjectFontWeight = subjectStyle.fontWeight || "regular";
const continuation = fragment.continuation ? canRenderCjk ? "\uFF08\u7EED\uFF09" : " (cont.)" : "";
const subject = ellipsize(
`${fragment.assignment.subject}${continuation}`,
fontBold,
SUBJECT_FONT,
font,
subjectFontSize,
CARD_WIDTH - CARD_PAD * 2
);
page.drawText(subject, {
drawTextWithWeight(page, subject, {
x: x + CARD_PAD,
y: top - CARD_PAD - 20,
size: SUBJECT_FONT,
font: fontBold,
size: subjectFontSize,
font,
fontLight,
fontBold,
fontWeight: subjectFontWeight,
color: colors.headerText
});
page.drawText(formatDate(fragment.assignment.date, dateDisplay), {
@ -56536,31 +56579,44 @@ function drawFragment(page, fragment, x, top, font, fontBold, dateDisplay, canRe
font,
color: colors.dateText
});
const contentStyle = fragment.assignment.style?.content || {};
const contentFontSize = typeof contentStyle.fontSize === "number" ? contentStyle.fontSize : CONTENT_FONT;
const contentFontWeight = contentStyle.fontWeight || "regular";
const contentLineHeight = fragment.contentLineHeight || contentFontSize * (CONTENT_LINE_HEIGHT / CONTENT_FONT);
let lineY = top - CARD_PAD - HEADER_HEIGHT - 12;
for (const line of fragment.lines) {
page.drawText(line || " ", {
drawTextWithWeight(page, line || " ", {
x: x + CARD_PAD,
y: lineY,
size: CONTENT_FONT,
size: contentFontSize,
font,
fontLight,
fontBold,
fontWeight: contentFontWeight,
color: colors.content
});
lineY -= CONTENT_LINE_HEIGHT;
lineY -= contentLineHeight;
}
if (fragment.showTags) {
drawTags(page, fragment.assignment.tags, x + CARD_PAD, y + CARD_PAD + 5, font, colors);
const tagStyle = fragment.assignment.style?.tags || {};
const tagFontSize = typeof tagStyle.fontSize === "number" ? tagStyle.fontSize : TAG_FONT;
const tagFontWeight = tagStyle.fontWeight || "regular";
drawTags(page, fragment.assignment.tags, x + CARD_PAD, y + CARD_PAD + 5, font, fontLight, fontBold, tagFontSize, tagFontWeight, colors);
} else {
const continued = canRenderCjk ? "\u6B63\u6587\u4E0B\u9875\u7EE7\u7EED" : "Continued on next card";
page.drawText(continued, {
drawTextWithWeight(page, continued, {
x: x + CARD_PAD,
y: y + CARD_PAD + 8,
size: TAG_FONT,
font,
fontLight,
fontBold,
fontWeight: "regular",
color: colors.muted
});
}
}
function renderAssignmentDocument(pdfDoc, document, font, fontBold, options = {}) {
function renderAssignmentDocument(pdfDoc, document, font, fontLight, fontBold, options = {}) {
const fragments = createFragments(document, font);
const pages = [];
const dateDisplay = options.dateDisplay ?? "zh";
@ -56584,6 +56640,7 @@ function renderAssignmentDocument(pdfDoc, document, font, fontBold, options = {}
PAGE_MARGIN + column * (CARD_WIDTH + COLUMN_GAP),
cursorTop,
font,
fontLight,
fontBold,
dateDisplay,
options.canRenderCjk !== false,
@ -56600,15 +56657,21 @@ async function createUafPdfWithFont(document, options = {}) {
const validated = validatePayload(document);
const csvBytes = new TextEncoder().encode(serializePayload(validated));
const pdfDoc = await PDFDocument_default.create();
let font;
let fontNormal, fontLight, fontBold;
if (options.useStandardFont) {
font = await pdfDoc.embedFont(StandardFonts.Helvetica);
fontNormal = fontLight = fontBold = await pdfDoc.embedFont(StandardFonts.Helvetica);
} else {
if (!options.fontBytes) throw new Error("fontBytes are required for CJK PDF rendering");
pdfDoc.registerFontkit(fontkit_es_default);
font = await pdfDoc.embedFont(options.fontBytes, { subset: options.subsetFont ?? true });
fontNormal = await pdfDoc.embedFont(options.fontBytes, { subset: options.subsetFont ?? true });
fontLight = options.fontLightBytes
? await pdfDoc.embedFont(options.fontLightBytes, { subset: options.subsetFont ?? true })
: fontNormal;
fontBold = options.fontBoldBytes
? await pdfDoc.embedFont(options.fontBoldBytes, { subset: options.subsetFont ?? true })
: fontNormal;
}
renderAssignmentDocument(pdfDoc, validated, font, font, {
renderAssignmentDocument(pdfDoc, validated, fontNormal, fontLight, fontBold, {
dateDisplay: options.useStandardFont ? "iso" : "zh",
canRenderCjk: !options.useStandardFont,
theme: options.theme
@ -56632,6 +56695,9 @@ function collectDocumentText(document) {
return [...new Set(parts.join(""))].join("");
}
async function subsetFontInBrowser(fontBytes, text, wasmUrl) {
if (fontBytes instanceof ArrayBuffer) {
fontBytes = new Uint8Array(fontBytes);
}
const response = await fetch(wasmUrl);
if (!response.ok) throw new Error(`Failed to load HarfBuzz subset engine: ${response.status}`);
const result = await WebAssembly.instantiate(await response.arrayBuffer());
@ -56844,13 +56910,24 @@ async function loadBrowserFont(options) {
return cachedFontBytes;
}
async function createUafPdf(document, options = {}) {
const fontBytes = await loadBrowserFont(options);
const wasmUrl = options.wasmUrl ?? new URL("../assets/hb-subset.wasm", import.meta.url);
const subset = await subsetFontInBrowser(fontBytes, collectDocumentText(document), wasmUrl);
const text = collectDocumentText(document);
const [fontBytes, fontLightBytes, fontBoldBytes] = await Promise.all([
loadBrowserFont(options),
options.fontLightBytes ? Promise.resolve(options.fontLightBytes) : Promise.resolve(null),
options.fontBoldBytes ? Promise.resolve(options.fontBoldBytes) : Promise.resolve(null),
]);
const [subsetNormal, subsetLight, subsetBold] = await Promise.all([
subsetFontInBrowser(fontBytes, text, wasmUrl),
fontLightBytes ? subsetFontInBrowser(fontLightBytes, text, wasmUrl) : Promise.resolve(null),
fontBoldBytes ? subsetFontInBrowser(fontBoldBytes, text, wasmUrl) : Promise.resolve(null),
]);
return createUafPdfWithFont(document, {
fontBytes: subset,
fontBytes: subsetNormal,
fontLightBytes: subsetLight,
fontBoldBytes: subsetBold,
subsetFont: false,
theme: options.theme
theme: options.theme,
});
}
async function createUafPdfFromCsv(csv, options = {}) {

View File

@ -38,6 +38,7 @@ export default defineConfig({
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: [
'**/*.{js,css,html,ico,png,svg,webmanifest,txt,json,woff2,ttf,mp3}',
],