Phase 11: version PDF (FR-15)

- เลข version ใหม่: เว็บ-generate = v0 ฉบับตั้งต้น (ไม่มี PDF), add-in save (Start Versioning + Save & Version) = v1+ มี PDF เสมอ
- PDF ผลิตจาก Word จริงผ่าน task pane เท่านั้น (getFileAsync Pdf + runtime guard, manifest ไม่เพิ่ม requirement); capture/upload เป็น best-effort — docx ผ่านแม้ PDF ล้ม + MessageBar เตือนตาม hasPdf
- Backend: DocumentVersion.PdfObjectKey (nullable, migration AddVersionPdf) + sibling .pdf ใน bucket docx-versions · multipart part pdf (ผิด type → 400) · /download + /download-link รับ ?format=pdf (ไม่มี → 404) · presigned ฝังชื่อไฟล์ไทย + _vN (RFC 5987)
- Web: เปิดไฟล์แทน blob-download — PDF แท็บใหม่, docx ผ่าน ms-word:ofe|u| พร้อม heuristic fallback ลิงก์สำรอง (useVersionOpen, versionDownload) · ปุ่ม "เปิดใน Word" ที่หน้า success ของฟอร์มสร้างเอกสาร
- Spec v7 + CONTEXT.md + ADR 0001 + แผน docs/plans/version-pdf.md
- Automated QA: backend 68 / add-in 63 / web 55 ผ่าน · manual acceptance ผ่านบน Word for Mac (2026-09-08) รวม fidelity + track changes — ค้างเฉพาะ Windows / Word on the web

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-09-08 14:01:13 +07:00
parent 618328ffa7
commit 39d610a781
39 changed files with 1387 additions and 212 deletions

View file

@ -1,7 +1,13 @@
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService";
import {
getCurrentDocumentFile,
getCurrentDocumentPdf,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "./fileService";
interface FileMockState {
getFileCalls: number;
fileTypes: unknown[];
requestedSlices: number[];
closeCalls: number;
}
@ -12,11 +18,17 @@ function installOfficeFileMock(
options: {
failSliceIndex?: number;
failClose?: boolean;
unsupported?: boolean;
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
sliceDataForm?: "buffer" | "view" | "array" | "base64";
} = {}
): FileMockState {
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
const state: FileMockState = {
fileTypes: [],
getFileCalls: 0,
requestedSlices: [],
closeCalls: 0,
};
const toSliceData = (bytes: Uint8Array): unknown => {
switch (options.sliceDataForm) {
@ -51,9 +63,10 @@ function installOfficeFileMock(
};
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
requirements: { isSetSupported: () => !options.unsupported },
document: {
getFileAsync: (
_fileType: unknown,
@ -61,6 +74,7 @@ function installOfficeFileMock(
callback: (result: unknown) => void
) => {
state.getFileCalls++;
state.fileTypes.push(_fileType);
callback({ status: "succeeded", value: file });
},
},
@ -135,3 +149,26 @@ describe("getCurrentDocumentFile", () => {
expect(blob.size).toBe(3);
});
});
describe("getCurrentDocumentPdf", () => {
it("captures PDF slices and closes the file", async () => {
const state = installOfficeFileMock([bytes(37, 80), bytes(68, 70)], {
sliceDataForm: "base64",
});
const pdf = await getCurrentDocumentPdf();
expect(pdf.type).toBe(PDF_CONTENT_TYPE);
expect(pdf.size).toBe(4);
expect(state.fileTypes).toEqual(["pdf"]);
expect(state.closeCalls).toBe(1);
});
it("rejects unsupported hosts without opening a file", async () => {
const state = installOfficeFileMock([], { unsupported: true });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.getFileCalls).toBe(0);
});
it("closes PDF even when slice reading fails", async () => {
const state = installOfficeFileMock([bytes(1)], { failSliceIndex: 0 });
await expect(getCurrentDocumentPdf()).rejects.toThrow();
expect(state.closeCalls).toBe(1);
});
});

View file

@ -9,26 +9,24 @@ export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
export const PDF_CONTENT_TYPE = "application/pdf";
const SLICE_SIZE = 1024 * 1024;
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
return new Error(result.error?.message || fallback);
}
function getFileAsync(): Promise<Office.File> {
function getFileAsync(fileType: Office.FileType): Promise<Office.File> {
return new Promise((resolve, reject) => {
try {
Office.context.document.getFileAsync(
Office.FileType.Compressed,
{ sliceSize: SLICE_SIZE },
(result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
Office.context.document.getFileAsync(fileType, { sliceSize: SLICE_SIZE }, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
);
});
} catch (error) {
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
}
@ -104,15 +102,26 @@ function closeFileQuietly(file: Office.File): Promise<void> {
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
*/
export async function getCurrentDocumentFile(): Promise<Blob> {
const file = await getFileAsync();
async function readFileAsBlob(fileType: Office.FileType, contentType: string): Promise<Blob> {
const file = await getFileAsync(fileType);
try {
const chunks: BlobPart[] = [];
for (let index = 0; index < file.sliceCount; index++) {
chunks.push(await getSliceAsync(file, index));
}
return new Blob(chunks, { type: DOCX_CONTENT_TYPE });
return new Blob(chunks, { type: contentType });
} finally {
await closeFileQuietly(file);
}
}
export async function getCurrentDocumentFile(): Promise<Blob> {
return readFileAsBlob(Office.FileType.Compressed, DOCX_CONTENT_TYPE);
}
export async function getCurrentDocumentPdf(): Promise<Blob> {
if (!Office.context.requirements.isSetSupported("PdfFile", "1.1")) {
throw new Error("Word นี้ไม่รองรับการสร้าง PDF");
}
return readFileAsBlob(Office.FileType.Pdf, PDF_CONTENT_TYPE);
}

View file

@ -5,12 +5,17 @@ import { installWordMock } from "../test/wordMock";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
function installOfficeDocumentMock(url: string | undefined): void {
function installOfficeDocumentMock(
url: string | undefined,
options: { failPdf?: boolean; emptyPdf?: boolean } = {}
): string[] {
const requested: string[] = [];
const slice = new Uint8Array([1, 2, 3, 4]);
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
FileType: { Compressed: "compressed", Pdf: "pdf", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
requirements: { isSetSupported: () => true },
document: {
url,
getFileAsync: (
@ -18,10 +23,15 @@ function installOfficeDocumentMock(url: string | undefined): void {
_options: unknown,
callback: (result: unknown) => void
) => {
requested.push(String(_fileType));
if (_fileType === "pdf" && options.failPdf) {
callback({ status: "failed", error: { message: "PDF unavailable" } });
return;
}
callback({
status: "succeeded",
value: {
sliceCount: 1,
sliceCount: _fileType === "pdf" && options.emptyPdf ? 0 : 1,
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
@ -31,6 +41,7 @@ function installOfficeDocumentMock(url: string | undefined): void {
},
},
};
return requested;
}
function seedSession(): void {
@ -266,3 +277,35 @@ describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
expect(form.get("message")).toBeNull();
});
});
describe.each(["start", "save"])("FR-15 %s", (flow) => {
it.each(["success", "failure", "empty", "storage-failure"])(
"PDF %s preserves docx upload",
async (mode) => {
installWordMock({ properties: { DocumentTrackingId: "doc" } });
const requested = installOfficeDocumentMock(undefined, {
failPdf: mode === "failure",
emptyPdf: mode === "empty",
});
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({
status: 201,
body: { versionId: "v", versionNumber: 1, hasPdf: mode === "success" },
})
);
const result = flow === "start" ? await startVersioning() : await saveAndVersion("doc", 0);
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(requested).toEqual(["compressed", "pdf"]);
expect(form.get("file")).toBeInstanceOf(Blob);
if (mode === "success" || mode === "storage-failure") {
expect((form.get("pdf") as File).name).toMatch(/\.pdf$/);
expect((form.get("pdf") as File).type).toBe("application/pdf");
} else expect(form.get("pdf")).toBeNull();
expect(result.hasPdf).toBe(mode === "success");
expect(fetchMock).toHaveBeenCalledTimes(1);
warn.mockRestore();
}
);
});

View file

@ -1,7 +1,7 @@
/* global Word, Office */
/* global Word, Office, console */
import * as authService from "./authService";
import { getCurrentDocumentFile } from "./fileService";
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
import {
generateGuid,
readContentControlFields,
@ -27,12 +27,14 @@ import { uploadVersion } from "./versioningService";
*/
export interface StartVersioningResult {
hasPdf: boolean;
documentId: string;
versionId: string;
versionNumber: number;
}
export interface SaveAndVersionResult {
hasPdf: boolean;
versionId: string;
versionNumber: number;
}
@ -71,10 +73,29 @@ async function uploadCurrentFile(
documentId: string,
clientVersionId: string,
fields: Record<string, string>,
message?: string
): Promise<{ versionId: string; versionNumber: number }> {
message?: string,
onProgress?: (progress: string) => void
): Promise<SaveAndVersionResult> {
const file = await getCurrentDocumentFile();
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName(), message);
let pdf: Blob | undefined;
onProgress?.("กำลังสร้างสำเนา PDF…");
try {
pdf = await getCurrentDocumentPdf();
if (pdf.size === 0) throw new Error("Word คืนไฟล์ PDF ว่าง");
} catch (error) {
console.warn("PDF capture failed:", error);
pdf = undefined;
}
onProgress?.("กำลังอัปโหลด version…");
return uploadVersion(
documentId,
file,
clientVersionId,
fields,
guessDocumentFileName(),
message,
pdf
);
}
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1
@ -109,7 +130,7 @@ export async function startVersioning(
// 4) upload version แรก
onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
return { documentId, ...result };
}
@ -147,7 +168,7 @@ export async function saveAndVersion(
// 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message);
const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) {

View file

@ -0,0 +1,33 @@
import { getDownloadLink, uploadVersion } from "./versioningService";
import { apiGetJson, apiSendExpectJson } from "./apiClient";
jest.mock("./apiClient", () => ({ apiGetJson: jest.fn(), apiSendExpectJson: jest.fn() }));
it("requests PDF download-link and keeps docx as default", async () => {
await getDownloadLink("d", "v", "pdf");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link?format=pdf",
expect.any(String)
);
await getDownloadLink("d", "v");
expect(apiGetJson).toHaveBeenLastCalledWith(
"/api/documents/d/versions/v/download-link",
expect.any(String)
);
});
it("uploads optional PDF alongside the docx in one request", async () => {
await uploadVersion(
"d",
new Blob(["docx"]),
"v",
undefined,
undefined,
undefined,
new Blob(["pdf"], { type: "application/pdf" })
);
const form = (apiSendExpectJson as jest.Mock).mock.calls[0][1].body as FormData;
expect((form.get("pdf") as File).name).toBe("d.pdf");
expect((form.get("pdf") as File).type).toBe("application/pdf");
expect((form.get("file") as File).name).toBe("d.docx");
});

View file

@ -16,11 +16,13 @@ export async function uploadVersion(
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string,
message?: string
message?: string,
pdf?: Blob
): Promise<UploadVersionResult> {
const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`);
form.append("clientVersionId", clientVersionId);
if (pdf) form.append("pdf", pdf, `${documentId}.pdf`);
if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields));
}
@ -44,8 +46,12 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
}
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
export function buildDownloadUrl(documentId: string, versionId: string): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
export function buildDownloadUrl(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download${format === "pdf" ? "?format=pdf" : ""}`;
}
export interface VersionDownloadLink {
@ -60,17 +66,22 @@ export interface VersionDownloadLink {
*/
export async function getDownloadLink(
documentId: string,
versionId: string
versionId: string,
format?: "docx" | "pdf"
): Promise<VersionDownloadLink> {
return apiGetJson<VersionDownloadLink>(
`/api/documents/${documentId}/versions/${versionId}/download-link`,
`/api/documents/${documentId}/versions/${versionId}/download-link${format === "pdf" ? "?format=pdf" : ""}`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
);
}
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
export async function downloadVersion(
documentId: string,
versionId: string,
format?: "docx" | "pdf"
): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId, format));
if (!response.ok) {
throw new ApiError(
response.status,