1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-08-12 17:40:32 +00:00

Compare commits

..

No commits in common. "cf9352fc1f8ca4b3a410d77618111e6eac864e81" and "5bfb5c9abb089ecb1856e7041e240540aa40e1c8" have entirely different histories.

11 changed files with 140 additions and 541 deletions

View File

@ -1,7 +1,3 @@
allowBuilds:
'@parcel/watcher': false
esbuild: false
sharp: false
onlyBuiltDependencies: onlyBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- esbuild - esbuild

Binary file not shown.

View File

@ -33,9 +33,7 @@
size="large" size="large"
rounded="xl" rounded="xl"
> ><v-icon icon="mdi-swap-vertical-bold"></v-icon></v-btn>
<v-icon icon="mdi-swap-vertical-bold" />
</v-btn>
</template> </template>
<v-list density="comfortable"> <v-list density="comfortable">
<v-list-item <v-list-item
@ -55,7 +53,7 @@
:prepend-icon=" :prepend-icon="
isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'
" "
rounded="xl" rounded="xl"
class="ml-2" class="ml-2"
size="large" size="large"
@click="$emit('toggle-fullscreen')" @click="$emit('toggle-fullscreen')"
@ -74,16 +72,16 @@
随机点名 随机点名
</v-btn> </v-btn>
<v-btn <v-btn
v-if="showExamScheduleButton" v-if="showExamScheduleButton"
prepend-icon="mdi-calendar-check" prepend-icon="mdi-calendar-check"
size="large" size="large"
class="ml-2" @click="$emit('add-exam-card')"
color="green" class="ml-2"
@click="$emit('add-exam-card')" color="green"
> >
考试看板 考试看板
</v-btn> </v-btn>
<v-btn <v-btn
@ -107,6 +105,7 @@
> >
添加测试卡片 添加测试卡片
</v-btn> </v-btn>
</div> </div>
<v-card <v-card

View File

@ -24,210 +24,99 @@
<v-card-text class="transfer-content"> <v-card-text class="transfer-content">
<template v-if="mode === 'export'"> <template v-if="mode === 'export'">
<v-tabs <v-text-field
v-if="mobile" v-model="exportDate"
v-model="mobileTab"
fixed-tabs
class="mb-3" class="mb-3"
label="导出日期"
prepend-inner-icon="mdi-calendar"
type="date"
hide-details
:disabled="busy"
@update:model-value="loadExportPreview"
/>
<v-progress-linear
v-if="loadingPreview"
class="mb-3"
indeterminate
/>
<v-alert
v-if="exportError"
class="mb-3"
type="warning"
variant="tonal"
> >
<v-tab value="edit"> {{ exportError }}
编辑 </v-alert>
</v-tab>
<v-tab value="preview">
预览
</v-tab>
</v-tabs>
<div <div
class="export-layout" v-if="previewRows.length"
:class="{ mobile: mobile, 'show-preview': mobile && mobileTab === 'preview' }" class="d-flex align-center mb-2"
> >
<div class="editor-section"> <v-checkbox-btn
<v-text-field :model-value="allValidSelected"
v-model="exportDate" :indeterminate="someValidSelected && !allValidSelected"
class="mb-3" @click="toggleAll"
label="导出日期" />
prepend-inner-icon="mdi-calendar" <span class="text-body-2">选择全部有效作业</span>
type="date" <v-spacer />
hide-details <span class="text-caption text-medium-emphasis">
:disabled="busy" 已选择 {{ selectedAssignments.length }} / {{ previewRows.length }}
@update:model-value="loadExportPreview" </span>
/> </div>
<v-progress-linear <v-list
v-if="loadingPreview" v-if="previewRows.length"
class="mb-3" border
indeterminate lines="three"
/> >
<v-list-item
<v-alert v-for="row in previewRows"
v-if="exportError" :key="row.id"
class="mb-3" >
type="warning" <template #prepend>
variant="tonal"
>
{{ exportError }}
</v-alert>
<div
v-if="previewRows.length"
class="d-flex align-center mb-2"
>
<v-checkbox-btn <v-checkbox-btn
:model-value="allValidSelected" v-model="row.selected"
:indeterminate="someValidSelected && !allValidSelected" :disabled="row.issues.length > 0"
@click="toggleAll"
/> />
<span class="text-body-2">选择全部有效作业</span> </template>
<v-spacer /> <v-list-item-title>{{ row.assignment.subject }}</v-list-item-title>
<span class="text-caption text-medium-emphasis"> <v-list-item-subtitle class="content-preview">
已选择 {{ selectedAssignments.length }} / {{ previewRows.length }} {{ row.assignment.content }}
</v-list-item-subtitle>
<div class="d-flex flex-wrap align-center mt-1 ga-1">
<v-chip
size="x-small"
variant="tonal"
>
{{ row.assignment.date }}
</v-chip>
<v-chip
v-for="tag in row.assignment.tags"
:key="tag"
size="x-small"
color="primary"
variant="tonal"
>
{{ tag }}
</v-chip>
<span
v-if="row.issues.length"
class="text-caption text-error"
>
{{ row.issues.join("") }}
</span> </span>
</div> </div>
</v-list-item>
</v-list>
<v-list <v-empty-state
v-if="previewRows.length" v-else-if="!loadingPreview && !exportError"
border icon="mdi-book-open-blank-variant-outline"
lines="three" text="该日期没有可导出的作业"
> title="暂无作业"
<v-list-item />
v-for="row in previewRows"
:key="row.id"
class="edit-list-item"
>
<template #prepend>
<v-checkbox-btn
v-model="row.selected"
: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 }}
</v-list-item-subtitle>
<div class="d-flex flex-wrap align-center mt-1 ga-1">
<v-chip
size="x-small"
variant="tonal"
>
{{ row.assignment.date }}
</v-chip>
<v-chip
v-for="tag in row.assignment.tags"
:key="tag"
size="x-small"
color="primary"
variant="tonal"
>
{{ tag }}
</v-chip>
<span
v-if="row.issues.length"
class="text-caption text-error"
>
{{ row.issues.join("") }}
</span>
</div>
</div>
</v-list-item>
</v-list>
<v-empty-state
v-else-if="!loadingPreview && !exportError"
icon="mdi-book-open-blank-variant-outline"
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>
<template v-else> <template v-else>
@ -358,12 +247,10 @@
</template> </template>
<script setup> <script setup>
import { computed, onUnmounted, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import dataProvider from "@/utils/dataProvider"; import dataProvider from "@/utils/dataProvider";
import { debounce } from "@/utils/debounce";
import { import {
buildUafPdfBytes,
createExportPreview, createExportPreview,
createImportPlan, createImportPlan,
downloadUafAssignments, downloadUafAssignments,
@ -400,22 +287,6 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "success", "error", "imported"]); const emit = defineEmits(["update:modelValue", "success", "error", "imported"]);
const { mobile } = useDisplay(); 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({ const dialog = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => emit("update:modelValue", value), set: (value) => emit("update:modelValue", value),
@ -430,21 +301,11 @@ const importPlan = ref(null);
const importError = ref(""); const importError = ref("");
const loadingImport = ref(false); const loadingImport = ref(false);
const importing = 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 busy = computed(() => exporting.value || importing.value || loadingImport.value);
const selectedAssignments = computed(() => const selectedAssignments = computed(() =>
previewRows.value.filter((row) => row.selected).map((row) => row.assignment), 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 validRows = computed(() => previewRows.value.filter((row) => row.issues.length === 0));
const allValidSelected = computed( const allValidSelected = computed(
() => validRows.value.length > 0 && validRows.value.every((row) => row.selected), () => validRows.value.length > 0 && validRows.value.every((row) => row.selected),
@ -474,11 +335,7 @@ const footerText = computed(() => {
watch( watch(
() => props.modelValue, () => props.modelValue,
(open, oldOpen) => { (open) => {
if (!open && oldOpen) {
releasePreviewUrl();
mobileTab.value = "edit";
}
if (!open) return; if (!open) return;
if (props.mode === "export") { if (props.mode === "export") {
exportDate.value = displayDate(props.currentDate); exportDate.value = displayDate(props.currentDate);
@ -491,20 +348,6 @@ watch(
}, },
); );
watch(
selectedAssignments,
() => {
generatePreview();
},
{ deep: true },
);
watch(globalStyle, () => generatePreview(), { deep: true });
onUnmounted(() => {
releasePreviewUrl();
});
function cloneBoard(board) { function cloneBoard(board) {
return JSON.parse(JSON.stringify(board || { homework: {}, attendance: {} })); return JSON.parse(JSON.stringify(board || { homework: {}, attendance: {} }));
} }
@ -544,47 +387,10 @@ function toggleAll() {
for (const row of validRows.value) row.selected = selected; 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() { async function exportSelected() {
exporting.value = true; exporting.value = true;
try { try {
const filename = await downloadUafAssignments(styledSelectedAssignments.value, exportDate.value); const filename = await downloadUafAssignments(selectedAssignments.value, exportDate.value);
emit("success", "导出成功", filename); emit("success", "导出成功", filename);
dialog.value = false; dialog.value = false;
} catch (error) { } catch (error) {
@ -675,97 +481,6 @@ function displayDate(date) {
-webkit-line-clamp: 2; -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: flex;
flex-direction: column;
flex: 1 1 auto;
}
.export-layout.mobile.show-preview .preview-placeholder {
max-height: none;
}
.export-layout.mobile:not(.show-preview) .editor-section {
display: block;
}
.action-select { .action-select {
min-width: 132px; min-width: 132px;
width: 132px; width: 132px;

View File

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

View File

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

View File

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

View File

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