dsh-cait 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/xlsx.js CHANGED
@@ -33,24 +33,33 @@ function sharedStringTable(xml) {
33
33
  ))
34
34
  }
35
35
 
36
- function firstSheetPath(entries) {
36
+ function workbookSheets(entries) {
37
37
  const workbook = zipText(entries, 'xl/workbook.xml')
38
38
  const rels = zipText(entries, 'xl/_rels/workbook.xml.rels')
39
- if (workbook && rels) {
40
- const sheet = workbook.match(/<sheet\b([^>]*)\/?>/i)
41
- const id = sheet ? attributes(sheet[1])['r:id'] : ''
42
- if (id) {
43
- const relation = [...rels.matchAll(/<Relationship\b([^>]*)\/?>/gi)]
44
- .map((match) => attributes(match[1]))
45
- .find((item) => item.Id === id)
46
- if (relation?.Target) {
47
- const target = relation.Target.replace(/^\/+/, '')
48
- return target.startsWith('xl/') ? target : `xl/${target}`
49
- }
50
- }
39
+ const relationMap = new Map(
40
+ [...rels.matchAll(/<Relationship\b([^>]*)\/?>/gi)]
41
+ .map((match) => attributes(match[1]))
42
+ .filter((item) => item.Id && item.Target)
43
+ .map((item) => [item.Id, item.Target]),
44
+ )
45
+ const sheets = []
46
+ for (const match of workbook.matchAll(/<sheet\b([^>]*)\/?>/gi)) {
47
+ const attrs = attributes(match[1])
48
+ const id = attrs['r:id']
49
+ const target = relationMap.get(id)
50
+ if (!target) continue
51
+ const path = target.replace(/^\/+/, '')
52
+ sheets.push({
53
+ name: attrs.name || `Sheet${sheets.length + 1}`,
54
+ path: path.startsWith('xl/') ? path : `xl/${path}`,
55
+ })
51
56
  }
52
- const fallback = entries.find((item) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(item.name))
53
- return fallback?.name ?? 'xl/worksheets/sheet1.xml'
57
+ if (sheets.length) return sheets
58
+ return entries
59
+ .map((entry) => entry.name)
60
+ .filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name))
61
+ .sort((left, right) => Number(left.match(/(\d+)\.xml$/)[1]) - Number(right.match(/(\d+)\.xml$/)[1]))
62
+ .map((path, index) => ({ name: `Sheet${index + 1}`, path }))
54
63
  }
55
64
 
56
65
  function columnIndex(reference) {
@@ -85,7 +94,12 @@ function parseRows(xml, shared) {
85
94
  for (const cellMatch of rowMatch[2].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/gi)) {
86
95
  const attrs = attributes(cellMatch[1])
87
96
  if (!attrs.r) continue
88
- cells.set(columnIndex(attrs.r), cellValue(attrs, cellMatch[2] ?? '', shared))
97
+ const body = cellMatch[2] ?? ''
98
+ cells.set(columnIndex(attrs.r), {
99
+ cell: attrs.r,
100
+ value: cellValue(attrs, body, shared),
101
+ formula: /<f\b/i.test(body),
102
+ })
89
103
  }
90
104
  if (cells.size) rows.set(rowNumber, cells)
91
105
  }
@@ -100,39 +114,53 @@ export function parseXlsx(buffer) {
100
114
  throw fail('XLSX_PARSE', `XLSX 文件不是有效的 ZIP/Excel 包: ${error.message}`)
101
115
  }
102
116
  const shared = sharedStringTable(zipText(entries, 'xl/sharedStrings.xml'))
103
- const sheetPath = firstSheetPath(entries)
104
- const sheetXml = zipText(entries, sheetPath)
105
- if (!sheetXml) throw fail('XLSX_PARSE', 'XLSX 中找不到工作表。')
106
- const rows = parseRows(sheetXml, shared)
107
- if (!rows.length) throw fail('XLSX_NO_UNITS', 'XLSX 中没有可用的行。')
108
-
109
- const maxColumns = rows.reduce((max, [, cells]) => Math.max(max, ...cells.keys(), -1), -1) + 1
110
- const first = rows[0][1]
111
- const header = (index) => String(first.get(index) ?? '').trim().toLowerCase()
112
- let sourceIndex = -1
113
- let targetIndex = -1
114
- for (let index = 0; index < maxColumns; index += 1) {
115
- if (SOURCE_HEADERS.has(header(index))) sourceIndex = index
116
- if (TARGET_HEADERS.has(header(index))) targetIndex = index
117
- }
118
- const hasHeader = sourceIndex >= 0 && targetIndex >= 0
119
- if (!hasHeader) {
120
- sourceIndex = 0
121
- targetIndex = maxColumns > 1 ? 1 : -1
122
- }
117
+ const sheets = workbookSheets(entries)
118
+ if (!sheets.length) throw fail('XLSX_PARSE', 'XLSX 中找不到工作表。')
123
119
 
124
120
  const segments = []
125
- for (const [rowNumber, cells] of rows) {
126
- if (hasHeader && rowNumber === rows[0][0]) continue
127
- const source = String(cells.get(sourceIndex) ?? '').trim()
128
- if (!source) continue
129
- const target = targetIndex >= 0 ? String(cells.get(targetIndex) ?? '').trim() : ''
130
- segments.push({
131
- source,
132
- target,
133
- paragraph: segments.length,
134
- meta: { format: 'xlsx', xlsxRow: rowNumber },
135
- })
121
+ for (const [sheetIndex, sheet] of sheets.entries()) {
122
+ const sheetXml = zipText(entries, sheet.path)
123
+ if (!sheetXml) continue
124
+ const rows = parseRows(sheetXml, shared)
125
+ if (!rows.length) continue
126
+
127
+ const maxColumns = rows.reduce((max, [, cells]) => Math.max(max, ...cells.keys(), -1), -1) + 1
128
+ const first = rows[0][1]
129
+ const header = (index) => String(first.get(index)?.value ?? '').trim().toLowerCase()
130
+ let sourceIndex = -1
131
+ let targetIndex = -1
132
+ for (let index = 0; index < maxColumns; index += 1) {
133
+ if (SOURCE_HEADERS.has(header(index))) sourceIndex = index
134
+ if (TARGET_HEADERS.has(header(index))) targetIndex = index
135
+ }
136
+ const hasHeader = sourceIndex >= 0 && targetIndex >= 0
137
+ if (!hasHeader) {
138
+ sourceIndex = 0
139
+ targetIndex = maxColumns > 1 ? 1 : -1
140
+ }
141
+
142
+ for (const [rowNumber, cells] of rows) {
143
+ if (hasHeader && rowNumber === rows[0][0]) continue
144
+ const sourceCell = cells.get(sourceIndex)
145
+ if (!sourceCell || sourceCell.formula) continue
146
+ const source = String(sourceCell.value ?? '').trim()
147
+ if (!source) continue
148
+ const targetCell = targetIndex >= 0 ? cells.get(targetIndex) : null
149
+ const target = targetCell ? String(targetCell.value ?? '').trim() : ''
150
+ segments.push({
151
+ source,
152
+ target,
153
+ paragraph: segments.length,
154
+ meta: {
155
+ format: 'xlsx',
156
+ xlsxRow: rowNumber,
157
+ sheet: sheetIndex + 1,
158
+ sheetName: sheet.name,
159
+ cell: sourceCell.cell,
160
+ ...(targetCell?.cell ? { targetCell: targetCell.cell } : {}),
161
+ },
162
+ })
163
+ }
136
164
  }
137
165
  if (!segments.length) throw fail('XLSX_NO_UNITS', 'XLSX 中没有可用的源文行。')
138
166
  return { format: 'xlsx', segments, warnings: [] }
@@ -153,23 +181,12 @@ function columnName(index) {
153
181
  return name
154
182
  }
155
183
 
156
- export function buildXlsx(doc) {
157
- const rows = [[
158
- inlineCell('A1', 'Source'),
159
- inlineCell('B1', 'Target'),
160
- ]]
161
- doc.segments.forEach((segment, index) => {
162
- const row = index + 2
163
- rows.push([
164
- inlineCell(`A${row}`, segment.source || ''),
165
- inlineCell(`B${row}`, segment.target || ''),
166
- ])
167
- })
184
+ function sheetXml(rows) {
168
185
  const sheetData = rows
169
186
  .map((cells, index) => ` <row r="${index + 1}">${cells.join('')}</row>`)
170
187
  .join('\n')
171
- const lastRow = rows.length + 1
172
- const sheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
188
+ const lastRow = Math.max(rows.length, 1)
189
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
173
190
  <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
174
191
  <dimension ref="A1:B${lastRow}"/>
175
192
  <sheetViews><sheetView workbookViewId="0"/></sheetViews>
@@ -178,12 +195,105 @@ export function buildXlsx(doc) {
178
195
  ${sheetData}
179
196
  </sheetData>
180
197
  </worksheet>`
198
+ }
199
+
200
+ function safeSheetName(name, used) {
201
+ let base = String(name ?? '').replace(/[\[\]:*?\/\\]/g, ' ').trim().slice(0, 31) || 'Sheet'
202
+ let candidate = base
203
+ let index = 2
204
+ while (used.has(candidate.toLowerCase())) {
205
+ const suffix = ` ${index}`
206
+ candidate = `${base.slice(0, 31 - suffix.length)}${suffix}`
207
+ index += 1
208
+ }
209
+ used.add(candidate.toLowerCase())
210
+ return candidate
211
+ }
212
+
213
+ function replaceCellText(xml, reference, value) {
214
+ const escaped = escapeXml(value)
215
+ const pattern = new RegExp(
216
+ `<c\\b([^>]*\\br="${reference}"[^>]*?)(?:/>|>([\\s\\S]*?)<\\/c>)`,
217
+ 'i',
218
+ )
219
+ let found = false
220
+ const output = String(xml).replace(pattern, (all, rawAttrs, body = '') => {
221
+ if (/<f\b/i.test(body)) return all
222
+ found = true
223
+ const attrs = rawAttrs.replace(/\s+t="[^"]*"/gi, '').replace(/\s*\/\s*$/g, '')
224
+ return `<c${attrs} t="inlineStr"><is><t xml:space="preserve">${escaped}</t></is></c>`
225
+ })
226
+ return { xml: output, found }
227
+ }
228
+
229
+ /**
230
+ * Write target text back into the original workbook. Rebuilding a workbook
231
+ * drops styles, column widths and merged ranges; patching the source zip keeps
232
+ * everything except the translated cell content intact.
233
+ */
234
+ export function patchXlsx(doc, template) {
235
+ let entries
236
+ try {
237
+ entries = readZipEntries(template)
238
+ } catch (error) {
239
+ throw fail('XLSX_SOURCE_INVALID', `XLSX 源文件不是有效的 ZIP/Excel 包: ${error.message}`)
240
+ }
241
+ const sheetPaths = new Map(workbookSheets(entries).map((sheet) => [sheet.name, sheet.path]))
242
+ const bySheet = new Map()
243
+ for (const segment of doc.segments) {
244
+ const sheetName = segment.meta?.sheetName
245
+ const cell = segment.meta?.targetCell
246
+ if (!sheetName || !cell) continue
247
+ if (!bySheet.has(sheetName)) bySheet.set(sheetName, new Map())
248
+ bySheet.get(sheetName).set(cell, String(segment.target || segment.source || ''))
249
+ }
250
+
251
+ let patched = 0
252
+ let missing = 0
253
+ const next = entries.map((entry) => {
254
+ const sheetName = [...sheetPaths.entries()].find(([, path]) => path === entry.name)?.[0]
255
+ const replacements = sheetName ? bySheet.get(sheetName) : null
256
+ if (!replacements?.size) return entry
257
+ let xml = entry.data.toString('utf8')
258
+ for (const [cell, text] of replacements) {
259
+ const result = replaceCellText(xml, cell, text)
260
+ xml = result.xml
261
+ if (result.found) patched += 1
262
+ else missing += 1
263
+ }
264
+ return { ...entry, data: Buffer.from(xml, 'utf8') }
265
+ })
266
+ return { buffer: writeZip(next), patched, missing }
267
+ }
268
+
269
+ export function buildXlsx(doc) {
270
+ const groups = new Map()
271
+ for (const segment of doc.segments) {
272
+ const key = segment.meta?.sheetName || `Sheet ${Number(segment.meta?.sheet) || 1}`
273
+ if (!groups.has(key)) groups.set(key, [])
274
+ groups.get(key).push(segment)
275
+ }
276
+ if (!groups.size) groups.set('Sheet 1', [])
277
+
278
+ const used = new Set()
279
+ const sheets = [...groups.entries()].map(([name, segments], index) => ({
280
+ name: safeSheetName(name, used),
281
+ path: `xl/worksheets/sheet${index + 1}.xml`,
282
+ rows: [
283
+ [inlineCell('A1', 'Source'), inlineCell('B1', 'Target')],
284
+ ...segments.map((segment, rowIndex) => [
285
+ inlineCell(`A${rowIndex + 2}`, segment.source || ''),
286
+ inlineCell(`B${rowIndex + 2}`, segment.target || ''),
287
+ ]),
288
+ ],
289
+ }))
290
+
181
291
  const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
182
292
  <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
183
293
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
184
294
  <Default Extension="xml" ContentType="application/xml"/>
185
295
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
186
- <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
296
+ ${sheets.map((sheet) => ` <Override PartName="/${sheet.path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('\n')}
187
297
  <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
188
298
  </Types>`
189
299
  const rootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@@ -192,12 +302,12 @@ ${sheetData}
192
302
  </Relationships>`
193
303
  const workbook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
194
304
  <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
195
- <sheets><sheet name="CAIT" sheetId="1" r:id="rId1"/></sheets>
305
+ <sheets>${sheets.map((sheet, index) => `<sheet name="${escapeXml(sheet.name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`).join('')}</sheets>
196
306
  </workbook>`
197
307
  const workbookRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
198
308
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
199
- <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
200
- <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
309
+ ${sheets.map((sheet, index) => ` <Relationship Id="rId${index + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`).join('\n')}
310
+ <Relationship Id="rId${sheets.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
201
311
  </Relationships>`
202
312
  const styles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
203
313
  <styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
@@ -212,7 +322,7 @@ ${sheetData}
212
322
  { name: '_rels/.rels', data: Buffer.from(rootRels, 'utf8') },
213
323
  { name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') },
214
324
  { name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') },
215
- { name: 'xl/worksheets/sheet1.xml', data: Buffer.from(sheet, 'utf8') },
325
+ ...sheets.map((sheet) => ({ name: sheet.path, data: Buffer.from(sheetXml(sheet.rows), 'utf8') })),
216
326
  { name: 'xl/styles.xml', data: Buffer.from(styles, 'utf8') },
217
327
  ])
218
328
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cait",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "license": "Apache-2.0",
5
5
  "author": {
6
6
  "name": "Jovi Lecca",