@@ -1,10 +1,11 @@
import { createHash } from "node:crypto" ;
import { createHash , randomUUID } 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 matter from "gray-matter" ;
import { buildApp } from "../dist/app.js" ;
import { createDocxMediaEngine } from "../dist/docx-media-engine.js" ;
import { createPdfGenerator } from "../dist/pdf-engine.js" ;
@@ -35,6 +36,27 @@ const fontPackRoot = path.resolve(
) ;
const decoder = new TextDecoder ( ) ;
function writeArtifactWithBusyFallback ( filePath , content ) {
try {
fs . writeFileSync ( filePath , content ) ;
return filePath ;
} catch ( error ) {
if ( error ? . code !== "EBUSY" ) {
throw error ;
}
const extension = path . extname ( filePath ) ;
const fallbackPath = path . join (
path . dirname ( filePath ) ,
` ${ path . basename ( filePath , extension ) } -attempt- ${ randomUUID ( ) } ${ extension } `
) ;
fs . writeFileSync ( fallbackPath , content ) ;
process . stderr . write (
` [DOCX R4 matrix] 既有产物被 Office 锁定,改写本次尝试文件: ${ path . basename ( fallbackPath ) } \n `
) ;
return fallbackPath ;
}
}
const inlineCodeContinuityProbes = [
{
id : "paragraph" ,
@@ -61,11 +83,99 @@ const inlineCodeContinuityMarkdown = `
- 列表前 \` mdtp_ic_l \` 列表后
| 门禁 | 内容 |
| 门禁 | 内容 | 邻接单元格 |
| --- | --- | --- |
| 行内代码 | 单元格前 \` mdtp_ic_c \` 单元格后 | 邻甲<br>邻乙 |
| 相邻隔离 | 旁甲<br/>旁乙 | 附甲 |
| 场景 | 内容 |
| --- | --- |
| 行内代码 | 单元格前 \` mdtp_ic_c \` 单元格后 |
| 壹式 | 标甲<br>标乙<br/>标丙<br />标丁 |
| 贰式 | 大甲<BR>大乙<BR/>大丙<BR />大丁 |
| 叁式 | 连甲<br><br>连乙 |
| 肆式 | <br>边界<br> |
| 伍式 | 转甲 \\ <br>转乙 <br> 转丙 |
| 陆式 | 属甲<br class="unsafe">属乙 |
| 柒式 | 码甲 \` <br> \` 码乙 |
表格外保留 外一<br>外二 原文。
\` \` \` html
围一<br>围二
\` \` \`
` ;
const docxBreakParagraphProbes = [
{
id : "basic" ,
marker : "标甲" ,
expectedText : "标甲标乙标丙标丁" ,
hardBreakCount : 3
} ,
{
id : "case-insensitive" ,
marker : "大甲" ,
expectedText : "大甲大乙大丙大丁" ,
hardBreakCount : 3
} ,
{
id : "multiple" ,
marker : "连甲" ,
expectedText : "连甲连乙" ,
hardBreakCount : 2
} ,
{
id : "boundary" ,
marker : "边界" ,
expectedText : "边界" ,
hardBreakCount : 2
} ,
{
id : "adjacent-a" ,
marker : "邻乙" ,
expectedText : "邻甲邻乙" ,
hardBreakCount : 1
} ,
{
id : "adjacent-b" ,
marker : "旁乙" ,
expectedText : "旁甲旁乙" ,
hardBreakCount : 1
} ,
{
id : "escaped-and-entity" ,
marker : "转甲" ,
expectedText : "转甲<br>转乙 <br> 转丙" ,
hardBreakCount : 0
} ,
{
id : "attribute" ,
marker : "属甲" ,
expectedText : "属甲<br class=\"unsafe\">属乙" ,
hardBreakCount : 0
} ,
{
id : "inline-code" ,
marker : "码甲" ,
expectedText : "码甲<br>码乙" ,
hardBreakCount : 0 ,
requiredCharacterStyle : "VerbatimChar"
} ,
{
id : "outside-table" ,
marker : "外一" ,
expectedText : "表格外保留 外一<br>外二 原文。" ,
hardBreakCount : 0
} ,
{
id : "fenced-code" ,
marker : "围一" ,
expectedText : "围一<br>围二" ,
hardBreakCount : 0 ,
requiredParagraphStyle : "SourceCode"
}
] ;
const contentTypes = new Map ( [
[ ".css" , "text/css; charset=utf-8" ] ,
[ ".html" , "text/html; charset=utf-8" ] ,
@@ -289,6 +399,59 @@ function inspectDocx(content) {
codeRunPattern . test ( paragraph )
} ;
} ) ;
const breakProbes = docxBreakParagraphProbes . map ( ( probe ) => {
const matchingParagraphs = paragraphs . map ( ( paragraph ) => ( {
paragraph ,
actualText : [
... paragraph . matchAll ( /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gu )
] . map ( ( match ) => match [ 1 ] ) . join ( "" )
} ) ) . filter ( ( { actualText } ) => actualText === probe . expectedText ) ;
const paragraph = matchingParagraphs [ 0 ] ? . paragraph ? ? "" ;
const actualText = matchingParagraphs [ 0 ] ? . actualText ? ? "" ;
const hardBreakCount =
paragraph . match ( /<w:br(?:\s[^>]*)?\s*\/>/gu ) ? . length ? ? 0 ;
const runs = paragraph . match ( /<w:r(?:\s[^>]*)?>[\s\S]*?<\/w:r>/gu ) ? ? [ ] ;
const characterStylePresent = ! probe . requiredCharacterStyle ||
runs . some ( ( run ) =>
run . includes ( ` w:rStyle w:val=" ${ probe . requiredCharacterStyle } " ` ) &&
run . includes ( "<br>" )
) ;
const paragraphStylePresent = ! probe . requiredParagraphStyle ||
new RegExp (
` <w:pStyle \\ s+w:val=" ${ probe . requiredParagraphStyle } " \\ s* \\ /> ` ,
"u"
) . test ( paragraph ) ;
return {
id : probe . id ,
marker : probe . marker ,
expectedText : probe . expectedText ,
actualText ,
expectedHardBreakCount : probe . hardBreakCount ,
hardBreakCount ,
paragraphCount : matchingParagraphs . length ,
characterStylePresent ,
paragraphStylePresent ,
passed :
matchingParagraphs . length === 1 &&
actualText === probe . expectedText &&
hardBreakCount === probe . hardBreakCount &&
characterStylePresent &&
paragraphStylePresent
} ;
} ) ;
const adjacentA = breakProbes . find ( ( probe ) => probe . id === "adjacent-a" ) ;
const adjacentB = breakProbes . find ( ( probe ) => probe . id === "adjacent-b" ) ;
const tableBreakSemantics = {
probes : breakProbes ,
adjacentCellsIsolated :
adjacentA ? . paragraphCount === 1 &&
adjacentB ? . paragraphCount === 1 &&
adjacentA . actualText !== adjacentB . actualText ,
passed :
breakProbes . every ( ( probe ) => probe . passed ) &&
adjacentA ? . paragraphCount === 1 &&
adjacentB ? . paragraphCount === 1
} ;
return {
bytes : content . byteLength ,
sha256 : sha256 ( content ) ,
@@ -314,6 +477,7 @@ function inspectDocx(content) {
inlineCodeContinuityPassed : inlineCodeContinuity . every (
( probe ) => probe . passed
) ,
tableBreakSemantics ,
requiredStylesPresent : [
"Normal" ,
"Heading1" ,
@@ -342,6 +506,103 @@ async function requestArtifact(origin, route, payload, contentType) {
return { response , content } ;
}
async function requestJson ( origin , route , payload ) {
const response = await fetch ( ` ${ origin } ${ route } ` , {
method : "POST" ,
headers : { "content-type" : "application/json" } ,
body : JSON . stringify ( payload )
} ) ;
const text = await response . text ( ) ;
assert ( response . ok , ` ${ route } 返回 ${ response . status } : ${ text } ` ) ;
assert (
response . headers . get ( "content-type" ) ? . includes ( "application/json" ) ,
` ${ route } MIME 无效: ${ response . headers . get ( "content-type" ) } `
) ;
return JSON . parse ( text ) ;
}
function inspectHtmlBreakSemantics ( articleHtml ) {
const cellFor = ( marker ) => {
const cells = articleHtml . match ( /<t[dh](?:\s[^>]*)?>[\s\S]*?<\/t[dh]>/gu ) ? ? [ ] ;
return cells . find ( ( cell ) => cell . includes ( marker ) ) ? ? "" ;
} ;
const fragmentFor = ( start , end ) => {
const startIndex = articleHtml . indexOf ( start ) ;
const endIndex = articleHtml . indexOf ( end , startIndex + start . length ) ;
return startIndex >= 0 && endIndex >= 0
? articleHtml . slice ( startIndex , endIndex + end . length )
: "" ;
} ;
const probes = [
{
id : "basic" ,
passed : cellFor ( "标甲" ) . includes (
"标甲<br />标乙<br />标丙<br />标丁"
)
} ,
{
id : "case-insensitive" ,
passed : cellFor ( "大甲" ) . includes (
"大甲<br />大乙<br />大丙<br />大丁"
)
} ,
{
id : "multiple" ,
passed : cellFor ( "连甲" ) . includes (
"连甲<br /><br />连乙"
)
} ,
{
id : "boundary" ,
passed : cellFor ( "边界" ) . includes ( "<br />边界<br />" )
} ,
{
id : "escaped-and-entity" ,
passed : cellFor ( "转甲" ) . includes (
"转甲<br>转乙 <br> 转丙"
)
} ,
{
id : "attribute-literal" ,
passed : cellFor ( "属甲" ) . includes (
"属甲<br class=\"unsafe\">属乙"
)
} ,
{
id : "inline-code-literal" ,
passed : cellFor ( "码甲" ) . includes (
"码甲<code><br></code>码乙"
)
} ,
{
id : "outside-table-literal" ,
passed : fragmentFor ( "外一" , "外二" ) . includes (
"外一<br>外二"
)
} ,
{
id : "fenced-code-literal" ,
passed : ( ( ) => {
const fragment = fragmentFor ( "围一" , "围二" ) ;
return fragment . includes ( "<" ) && fragment . includes ( "br" ) &&
fragment . includes ( ">" ) && ! /<br(?:\s|\/?>)/iu . test ( fragment ) ;
} ) ( )
}
] ;
const adjacentA = cellFor ( "邻甲" ) ;
const adjacentB = cellFor ( "旁甲" ) ;
const adjacentCellsIsolated =
adjacentA . includes ( "邻甲<br />邻乙" ) &&
adjacentB . includes ( "旁甲<br />旁乙" ) &&
! adjacentA . includes ( "旁甲" ) &&
! adjacentB . includes ( "邻甲" ) ;
return {
probes ,
adjacentCellsIsolated ,
passed : probes . every ( ( probe ) => probe . passed ) && adjacentCellsIsolated
} ;
}
function responseDiagnostics ( response , kind ) {
return {
serverTiming : response . headers . get ( "server-timing" ) ,
@@ -410,6 +671,10 @@ assert(
) ;
const selectedThemeId =
process . env . MD _TO _PDF _R4 _THEME _ID ? . trim ( ) || undefined ;
const externalMarkdownPath =
process . env . MD _TO _PDF _R4 _MARKDOWN _PATH ? . trim ( ) || undefined ;
const appendInlineCodeProbes =
process . env . MD _TO _PDF _R4 _APPEND _INLINE _CODE _PROBES ? . trim ( ) !== "0" ;
const exportConfigOverride = readExportConfigOverride ( ) ;
const themes = selectedThemeId
? allThemes . filter ( ( theme ) => theme . id === selectedThemeId )
@@ -468,9 +733,13 @@ try {
for ( const theme of themes ) {
capturedDocxDocumentLayout = undefined ;
process . stderr . write ( ` [DOCX R4 matrix] generating ${ theme . id } \n ` ) ;
const samplePath = path . join ( samplesDirectory , ` ${ theme . id } .md ` ) ;
const samplePath = externalMarkdownPath
? path . resolve ( repositoryDirectory , externalMarkdownPath )
: path . join ( samplesDirectory , ` ${ theme . id } .md ` ) ;
assert ( fs . existsSync ( samplePath ) , ` 主题缺少验收示例: ${ theme . id } ` ) ;
const markdown = ` ${ fs . readFileSync ( samplePath , "utf8" ) . trimEnd ( ) } ${ inlineCodeContinuityMarkdown } ` ;
const sourceMarkdown = fs . readFileSync ( samplePath , "utf8" ) ;
const markdown = ` ${ sourceMarkdown . trimEnd ( ) } ${ appendInlineCodeProbes ? inlineCodeContinuityMarkdown : "" } ` ;
const sourceMetadata = matter ( sourceMarkdown ) . data ;
const exportConfig = mergeExportConfig ( {
... defaultExportConfig ,
name : ` ${ theme . name } R4 生产链验收 ` ,
@@ -495,11 +764,21 @@ try {
} , exportConfigOverride ) ;
const payload = {
markdown ,
fileName : ` ${ theme . id } .md ` ,
fileName : path . basename ( samplePath ) ,
language : "zh-CN" ,
resources : [ ] ,
exportConfig
} ;
const rendered = await requestJson ( origin , "/api/render" , payload ) ;
const htmlBreakSemantics = appendInlineCodeProbes
? inspectHtmlBreakSemantics ( rendered . articleHtml )
: undefined ;
if ( appendInlineCodeProbes ) {
assert (
htmlBreakSemantics . passed ,
` 主题 ${ theme . id } 的 Chromium br 语义门禁失败: ${ JSON . stringify ( htmlBreakSemantics ) } `
) ;
}
const pdf = await requestArtifact (
origin ,
"/api/pdf" ,
@@ -526,10 +805,16 @@ try {
inspection . requiredStylesPresent ,
` 主题 ${ theme . id } 缺少标准 Word 样式 `
) ;
assert (
inspection . inlineCodeContinuityPassed ,
` 主题 ${ theme . id } 的行内代码连续性门禁失败: ${ JSON . stringify ( inspection . inlineCodeContinuity ) } `
) ;
if ( appendInlineCodeProbes ) {
assert (
inspection . inlineCodeContinuityPassed ,
` 主题 ${ theme . id } 的行内代码连续性门禁失败: ${ JSON . stringify ( inspection . inlineCodeContinuity ) } `
) ;
assert (
inspection . tableBreakSemantics . passed ,
` 主题 ${ theme . id } 的表格 br 换行门禁失败: ${ JSON . stringify ( inspection . tableBreakSemantics ) } `
) ;
}
const standard = standardByTheme . get ( theme . id ) ;
assert ( standard , ` 标准矩阵缺少主题 ${ theme . id } ` ) ;
assert (
@@ -545,8 +830,8 @@ try {
) ;
}
const expectsOfficialDualEndedRow = Boolean (
standard . metadata ? . document ? . profile === "official" &&
standard . metadata . document . signatory
sourceMetadata . document ? . profile === "official" &&
sourceMetadata . document . signatory
) ;
if ( expectsOfficialDualEndedRow ) {
assert (
@@ -566,9 +851,12 @@ try {
}
const pdfPath = path . join ( pdfDirectory , ` ${ theme . id } .pdf ` ) ;
const docxPath = path . join ( docxDirectory , ` ${ theme . id } .docx ` ) ;
const preferredDocxPath = path . join ( docxDirectory , ` ${ theme . id } .docx ` ) ;
fs . writeFileSync ( pdfPath , pdf . content ) ;
fs . writeFileSync ( docxPath , docx . content ) ;
const docxPath = writeArtifactWithBusyFallback (
preferredDocxPath ,
docx . content
) ;
results . push ( {
id : theme . id ,
name : theme . name ,
@@ -576,6 +864,7 @@ try {
compatibleProfiles : theme . compatibleProfiles ,
sample : path . relative ( repositoryDirectory , samplePath ) ,
exportConfig ,
htmlBreakSemantics ,
pdf : {
outputFile : path . relative ( repositoryDirectory , pdfPath ) ,
bytes : pdf . content . byteLength ,