@@ -0,0 +1,489 @@
import { createHash } from "node:crypto" ;
import fs from "node:fs" ;
import net from "node:net" ;
import path from "node:path" ;
import { fileURLToPath } from "node:url" ;
import { defaultExportConfig , themeManifestSchema } from "@md-to-pdf/core" ;
import { unzipSync } from "fflate" ;
import { buildApp } from "../dist/app.js" ;
import { createDocxMediaEngine } from "../dist/docx-media-engine.js" ;
import { createPdfGenerator } from "../dist/pdf-engine.js" ;
const directory = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const repositoryDirectory = path . resolve ( directory , "../../.." ) ;
const themesDirectory = path . join ( repositoryDirectory , "themes" ) ;
const samplesDirectory = path . join ( repositoryDirectory , "samples" , "themes" ) ;
const webDirectory = path . join ( repositoryDirectory , "apps" , "web" , "dist" ) ;
const outputDirectory = path . resolve (
process . env . MD _TO _PDF _R4 _OUTPUT _DIR ? . trim ( ) ||
path . join ( repositoryDirectory , "output" , "docx-r4" )
) ;
const pdfDirectory = path . join ( outputDirectory , "chromium-pdf" ) ;
const docxDirectory = path . join ( outputDirectory , "docx" ) ;
const standardReportPath = path . join (
repositoryDirectory ,
"output" ,
"docx-theme-matrix" ,
"theme-matrix-report.json"
) ;
const fontPackRoot = path . resolve (
process . env . MD _TO _PDF _FONT _PACK _DIR ? . trim ( ) ||
path . join ( repositoryDirectory , "output" , "font-packs" , "root" )
) ;
const decoder = new TextDecoder ( ) ;
const contentTypes = new Map ( [
[ ".css" , "text/css; charset=utf-8" ] ,
[ ".html" , "text/html; charset=utf-8" ] ,
[ ".js" , "text/javascript; charset=utf-8" ] ,
[ ".mjs" , "text/javascript; charset=utf-8" ] ,
[ ".png" , "image/png" ] ,
[ ".svg" , "image/svg+xml" ] ,
[ ".ttf" , "font/ttf" ] ,
[ ".woff" , "font/woff" ] ,
[ ".woff2" , "font/woff2" ]
] ) ;
function assert ( condition , message ) {
if ( ! condition ) {
throw new Error ( message ) ;
}
}
function sha256 ( content ) {
return createHash ( "sha256" ) . update ( content ) . digest ( "hex" ) ;
}
function readJson ( filePath ) {
return JSON . parse ( fs . readFileSync ( filePath , "utf8" ) ) ;
}
function readExportConfigOverride ( ) {
const source = process . env . MD _TO _PDF _R4 _EXPORT _CONFIG _JSON ? . trim ( ) ;
if ( ! source ) {
return undefined ;
}
const parsed = JSON . parse ( source ) ;
assert (
parsed && typeof parsed === "object" && ! Array . isArray ( parsed ) ,
"R4 导出配置覆盖必须是 JSON 对象"
) ;
return parsed ;
}
function mergeExportConfig ( base , override ) {
if ( ! override ) {
return base ;
}
return {
... base ,
... override ,
mermaid : { ... base . mermaid , ... override . mermaid } ,
paper : {
... base . paper ,
... override . paper ,
margins : { ... base . paper . margins , ... override . paper ? . margins }
} ,
header : {
... base . header ,
... override . header ,
left : { ... base . header . left , ... override . header ? . left } ,
center : { ... base . header . center , ... override . header ? . center } ,
right : { ... base . header . right , ... override . header ? . right }
} ,
footer : { ... base . footer , ... override . footer } ,
metadata : { ... base . metadata , ... override . metadata } ,
print : { ... base . print , ... override . print }
} ;
}
function readThemes ( ) {
return fs
. readdirSync ( themesDirectory , { withFileTypes : true } )
. filter (
( entry ) =>
entry . isDirectory ( ) &&
fs . existsSync ( path . join ( themesDirectory , entry . name , "theme.json" ) )
)
. map ( ( entry ) =>
themeManifestSchema . parse (
readJson ( path . join ( themesDirectory , entry . name , "theme.json" ) )
)
)
. sort ( ( first , second ) => first . id . localeCompare ( second . id , "en" ) ) ;
}
function reservePort ( ) {
return new Promise ( ( resolve , reject ) => {
const server = net . createServer ( ) ;
server . once ( "error" , reject ) ;
server . listen ( 0 , "127.0.0.1" , ( ) => {
const address = server . address ( ) ;
const port =
typeof address === "object" && address ? address . port : undefined ;
server . close ( ( error ) =>
error || ! port
? reject ( error ? ? new Error ( "无法分配 R4 验收端口" ) )
: resolve ( port )
) ;
} ) ;
} ) ;
}
function resolveWebFile ( relativePath ) {
const resolved = path . resolve ( webDirectory , relativePath ) ;
const relative = path . relative ( webDirectory , resolved ) ;
assert (
relative !== ".." &&
! relative . startsWith ( ` .. ${ path . sep } ` ) &&
! path . isAbsolute ( relative ) ,
` Web 资源路径越界: ${ relativePath } `
) ;
return resolved ;
}
function registerWebRuntime ( app ) {
app . get ( "/preview-frame.html" , async ( _request , reply ) => {
const content = fs . readFileSync ( resolveWebFile ( "preview-frame.html" ) ) ;
return reply . type ( "text/html; charset=utf-8" ) . send ( content ) ;
} ) ;
app . get ( "/assets/*" , async ( request , reply ) => {
const relativePath = String ( request . params [ "*" ] ? ? "" ) ;
const filePath = resolveWebFile ( path . join ( "assets" , relativePath ) ) ;
const stat = fs . statSync ( filePath ) ;
assert ( stat . isFile ( ) , ` Web 资源不是普通文件: ${ relativePath } ` ) ;
return reply
. type (
contentTypes . get ( path . extname ( filePath ) . toLowerCase ( ) ) ||
"application/octet-stream"
)
. send ( fs . readFileSync ( filePath ) ) ;
} ) ;
}
function inspectDocx ( content ) {
const entries = unzipSync ( content ) ;
const documentXml = decoder . decode ( entries [ "word/document.xml" ] ) ;
const stylesXml = decoder . decode ( entries [ "word/styles.xml" ] ) ;
const fontTableXml = decoder . decode ( entries [ "word/fontTable.xml" ] ) ;
const fontParts = Object . keys ( entries ) . filter ( ( name ) =>
name . startsWith ( "word/fonts/" )
) ;
const fontNames = [
... fontTableXml . matchAll ( /<w:font\s+w:name="([^"]+)"/gu )
] . map ( ( match ) => match [ 1 ] ) ;
const paragraphs = [
... documentXml . matchAll ( /<w:p(?:\s|>)[\s\S]*?<\/w:p>/gu )
] . map ( ( match ) => match [ 0 ] ) ;
const authorParagraphCount = paragraphs . filter ( ( paragraph ) =>
/ < w : pStyle \ s + w : val = "Author" \ s * \ / > / u . t e s t ( p a r a g r a p h )
) . length ;
const officialIssueRows = paragraphs . filter ( ( paragraph ) =>
/ < w : pStyle \ s + w : val = "MdOfficialIssueRow" \ s * \ / > / u . t e s t (
paragraph
)
) ;
const officialIssueRowsWithRightTab = officialIssueRows . filter (
( paragraph ) =>
[ ... paragraph . matchAll ( /<w:tab(?:\s[^>]*)?\s*\/>/gu ) ] . some (
( match ) =>
/ \ bw : val = "right" / u . test ( match [ 0 ] ) &&
/\bw:pos="\d+"/u . test ( match [ 0 ] )
)
) ;
const officialIssueRowContentTabCount = officialIssueRows . reduce (
( count , paragraph ) =>
count + ( paragraph . match ( /<w:tab\s*\/>/gu ) ? . length ? ? 0 ) ,
0
) ;
return {
bytes : content . byteLength ,
sha256 : sha256 ( content ) ,
partCount : Object . keys ( entries ) . length ,
sectionCount : documentXml . match ( /<w:sectPr(?:\s|>)/gu ) ? . length ? ? 0 ,
embeddedFontPartCount : fontParts . length ,
fontNames : [ ... new Set ( fontNames ) ] . sort ( ( first , second ) =>
first . localeCompare ( second , "en" )
) ,
optionalFontPackApplied : fontTableXml . includes ( "MdTP Serif SC" ) ,
hasAltChunk : /<w:altChunk(?:\s|>)/u . test ( documentXml ) ,
internalMarkerCount :
documentXml . match ( /MD_TO_PDF_(?:CONTAINER|SECTION)_/gu ) ? . length ? ? 0 ,
authorParagraphCount ,
officialIssueRowCount : officialIssueRows . length ,
officialIssueRowRightTabCount :
officialIssueRowsWithRightTab . length ,
officialIssueRowContentTabCount ,
requiredStylesPresent : [
"Normal" ,
"Heading1" ,
"SourceCode" ,
"Table" ,
"Caption"
] . every ( ( styleId ) => stylesXml . includes ( ` w:styleId=" ${ styleId } " ` ) )
} ;
}
async function requestArtifact ( origin , route , payload , contentType ) {
const response = await fetch ( ` ${ origin } ${ route } ` , {
method : "POST" ,
headers : { "content-type" : "application/json" } ,
body : JSON . stringify ( payload )
} ) ;
const content = new Uint8Array ( await response . arrayBuffer ( ) ) ;
assert (
response . ok ,
` ${ route } 返回 ${ response . status } : ${ decoder . decode ( content ) } `
) ;
assert (
response . headers . get ( "content-type" ) ? . includes ( contentType ) ,
` ${ route } MIME 无效: ${ response . headers . get ( "content-type" ) } `
) ;
return { response , content } ;
}
function responseDiagnostics ( response , kind ) {
return {
serverTiming : response . headers . get ( "server-timing" ) ,
pageCount :
kind === "pdf"
? Number ( response . headers . get ( "x-pdf-page-count" ) )
: undefined ,
warningCount :
kind === "docx"
? Number ( response . headers . get ( "x-docx-warning-count" ) )
: undefined ,
echartsErrorCount : Number (
response . headers . get ( "x-echarts-error-count" )
) ,
mermaidErrorCount : Number (
response . headers . get ( "x-mermaid-error-count" )
)
} ;
}
assert ( fs . existsSync ( standardReportPath ) , "缺少本轮标准 DOCX 主题矩阵报告" ) ;
assert (
fs . statSync ( fontPackRoot ) . isDirectory ( ) ,
` 缺少有效字体包根目录: ${ fontPackRoot } `
) ;
const allThemes = readThemes ( ) ;
assert (
allThemes . length === 14 ,
` 内置主题数量应为 14,实际为 ${ allThemes . length } `
) ;
const selectedThemeId =
process . env . MD _TO _PDF _R4 _THEME _ID ? . trim ( ) || undefined ;
const exportConfigOverride = readExportConfigOverride ( ) ;
const themes = selectedThemeId
? allThemes . filter ( ( theme ) => theme . id === selectedThemeId )
: allThemes ;
assert (
themes . length > 0 ,
` R4 验收主题不存在: ${ selectedThemeId } `
) ;
const standardReport = readJson ( standardReportPath ) ;
assert (
standardReport . themeCount === allThemes . length ,
"标准 DOCX 主题矩阵与 R4 主题数量不一致"
) ;
const standardByTheme = new Map (
standardReport . results . map ( ( result ) => [ result . id , result ] )
) ;
fs . mkdirSync ( pdfDirectory , { recursive : true } ) ;
fs . mkdirSync ( docxDirectory , { recursive : true } ) ;
const port = await reservePort ( ) ;
const origin = ` http://127.0.0.1: ${ port } ` ;
const pdfGenerator = createPdfGenerator ( { renderOrigin : origin } ) ;
const docxMediaAdapter = createDocxMediaEngine ( { renderOrigin : origin } ) ;
const app = buildApp ( {
logger : { level : "error" } ,
pdfGenerator ,
docxMediaAdapter ,
fontPacks : {
roots : [ fontPackRoot ] ,
appVersion : "0.6.0"
}
} ) ;
registerWebRuntime ( app ) ;
const results = [ ] ;
try {
await app . listen ( { port , host : "127.0.0.1" } ) ;
const capabilityResponse = await fetch ( ` ${ origin } /api/docx/capability ` ) ;
const capability = await capabilityResponse . json ( ) ;
assert (
capabilityResponse . ok &&
capability . status === "available" &&
capability . detectedVersion === "3.9.0.2" ,
` DOCX capability 无效: ${ JSON . stringify ( capability ) } `
) ;
for ( const theme of themes ) {
process . stderr . write ( ` [DOCX R4 matrix] generating ${ theme . id } \n ` ) ;
const samplePath = path . join ( samplesDirectory , ` ${ theme . id } .md ` ) ;
assert ( fs . existsSync ( samplePath ) , ` 主题缺少验收示例: ${ theme . id } ` ) ;
const markdown = fs . readFileSync ( samplePath , "utf8" ) ;
const exportConfig = mergeExportConfig ( {
... defaultExportConfig ,
name : ` ${ theme . name } R4 生产链验收 ` ,
themeId : theme . id ,
pageDecorationsMode : "theme" ,
header : structuredClone (
theme . pageDefaults ? . header ? ? defaultExportConfig . header
) ,
footer : structuredClone (
theme . pageDefaults ? . footer ? ? defaultExportConfig . footer
) ,
paper : {
... defaultExportConfig . paper ,
format : "A4" ,
orientation : "portrait" ,
marginMode : "theme" ,
margins : structuredClone (
theme . pageDefaults ? . margins ? ?
defaultExportConfig . paper . margins
)
}
} , exportConfigOverride ) ;
const payload = {
markdown ,
fileName : ` ${ theme . id } .md ` ,
language : "zh-CN" ,
resources : [ ] ,
exportConfig
} ;
const pdf = await requestArtifact (
origin ,
"/api/pdf" ,
payload ,
"application/pdf"
) ;
assert (
decoder . decode ( pdf . content . subarray ( 0 , 5 ) ) === "%PDF-" ,
` 主题 ${ theme . id } 的 PDF 签名无效 `
) ;
const docx = await requestArtifact (
origin ,
"/api/docx" ,
payload ,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) ;
const inspection = inspectDocx ( docx . content ) ;
assert ( ! inspection . hasAltChunk , ` 主题 ${ theme . id } 包含 altChunk ` ) ;
assert (
inspection . internalMarkerCount === 0 ,
` 主题 ${ theme . id } 残留内部结构标记 `
) ;
assert (
inspection . requiredStylesPresent ,
` 主题 ${ theme . id } 缺少标准 Word 样式 `
) ;
const standard = standardByTheme . get ( theme . id ) ;
assert ( standard , ` 标准矩阵缺少主题 ${ theme . id } ` ) ;
assert (
standard . inspection . structureCoverage === 1 &&
standard . inspection . titlePolicyPassed &&
standard . inspection . titleDeduplicationPassed ,
` 主题 ${ theme . id } 的标准结构门禁未通过 `
) ;
if ( standard . inspection . profile ) {
assert (
inspection . authorParagraphCount === 0 ,
` 结构化主题 ${ theme . id } 残留重复 Author 段落 `
) ;
}
const expectsOfficialDualEndedRow = Boolean (
standard . metadata ? . document ? . profile === "official" &&
standard . metadata . document . signatory
) ;
if ( expectsOfficialDualEndedRow ) {
assert (
inspection . officialIssueRowCount > 0 &&
inspection . officialIssueRowCount ===
inspection . officialIssueRowRightTabCount &&
inspection . officialIssueRowContentTabCount ===
inspection . officialIssueRowCount ,
` 主题 ${ theme . id } 的公文双端行缺少右制表位或签发人分隔 `
) ;
} else {
assert (
inspection . officialIssueRowRightTabCount === 0 &&
inspection . officialIssueRowContentTabCount === 0 ,
` 主题 ${ theme . id } 的单端公文行包含无意义制表位 `
) ;
}
const pdfPath = path . join ( pdfDirectory , ` ${ theme . id } .pdf ` ) ;
const docxPath = path . join ( docxDirectory , ` ${ theme . id } .docx ` ) ;
fs . writeFileSync ( pdfPath , pdf . content ) ;
fs . writeFileSync ( docxPath , docx . content ) ;
results . push ( {
id : theme . id ,
name : theme . name ,
category : theme . category ,
compatibleProfiles : theme . compatibleProfiles ,
sample : path . relative ( repositoryDirectory , samplePath ) ,
exportConfig ,
pdf : {
outputFile : path . relative ( repositoryDirectory , pdfPath ) ,
bytes : pdf . content . byteLength ,
sha256 : sha256 ( pdf . content ) ,
diagnostics : responseDiagnostics ( pdf . response , "pdf" )
} ,
docx : {
outputFile : path . relative ( repositoryDirectory , docxPath ) ,
inspection ,
diagnostics : responseDiagnostics ( docx . response , "docx" )
} ,
standardInspection : standard . inspection ,
expectsOfficialDualEndedRow
} ) ;
}
} finally {
await app . close ( ) ;
}
const enhancedThemes = results . filter (
( result ) => result . docx . inspection . optionalFontPackApplied
) ;
if ( themes . some ( ( theme ) => theme . id === "gov-red-standard" ) ) {
assert (
enhancedThemes . some ( ( result ) => result . id === "gov-red-standard" ) ,
"有效字体包未增强 gov-red-standard"
) ;
}
for ( const result of results ) {
assert (
result . pdf . diagnostics . pageCount > 0 ,
` 主题 ${ result . id } 的 PDF 页数无效 `
) ;
assert (
result . pdf . diagnostics . echartsErrorCount === 0 &&
result . pdf . diagnostics . mermaidErrorCount === 0 ,
` 主题 ${ result . id } 的 PDF 图表渲染失败 `
) ;
assert (
result . docx . diagnostics . echartsErrorCount === 0 &&
result . docx . diagnostics . mermaidErrorCount === 0 ,
` 主题 ${ result . id } 的 DOCX 图表渲染失败 `
) ;
}
const report = {
schemaVersion : 1 ,
generatedAt : new Date ( ) . toISOString ( ) ,
origin ,
themeCount : themes . length ,
totalThemeCount : allThemes . length ,
selectedThemeId ,
fontPackRoot ,
standardReport : path . relative ( repositoryDirectory , standardReportPath ) ,
outputDirectory : path . relative ( repositoryDirectory , outputDirectory ) ,
enhancedThemeIds : enhancedThemes . map ( ( result ) => result . id ) ,
results
} ;
const reportPath = path . join ( outputDirectory , "r4-matrix-report.json" ) ;
fs . writeFileSync ( reportPath , ` ${ JSON . stringify ( report , null , 2 ) } \n ` , "utf8" ) ;
process . stdout . write ( ` ${ JSON . stringify ( report , null , 2 ) } \n ` ) ;