Skip to content

Commit de5cc87

Browse files
feat: add Excel export feature; fix and optimize ConnectorLine implementation
1 parent 0c1a268 commit de5cc87

File tree

14 files changed

+459
-95
lines changed

14 files changed

+459
-95
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
"@floating-ui/dom": "^1.6.10",
5858
"antd": "^5.15.3",
5959
"dayjs": "^1.11.13",
60+
"exceljs": "^4.4.0",
61+
"file-saver": "^2.0.5",
6062
"i18next": "^23.15.1",
6163
"konva": "^9.3.6",
6264
"nanoid": "^5.0.7",

src/annot/index.ts

Lines changed: 206 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import { PDFDocument, PDFName, PDFPage } from 'pdf-lib'
22
import { PDFViewerApplication } from 'pdfjs'
3-
import { IAnnotationStore, PdfjsAnnotationType } from '../const/definitions'
3+
import { annotationDefinitions, CommentStatus, IAnnotationStore, PdfjsAnnotationType } from '../const/definitions'
44
import { TextParser } from './parse_text'
5+
import ExcelJS from 'exceljs'
6+
import { saveAs } from 'file-saver'
57
import { AnnotationParser } from './parse'
68
import { HighlightParser } from './parse_highlight'
79
import { UnderlineParser } from './parse_underline'
810
import { StrikeOutParser } from './parse_strikeout'
911
import { SquareParser } from './parse_square'
1012
import { CircleParser } from './parse_circle'
1113
import { InkParser } from './parse_ink'
12-
import { getTimestampString } from '../utils/utils'
14+
import { formatPDFDate, getPDFDateTimestamp, getTimestampString } from '../utils/utils'
1315
import { FreeTextParser } from './parse_freetext'
1416
import { StampParser } from './parse_stamp'
1517
import { LineParser } from './parse_line'
1618
import { PolylineParser } from './parse_polyline'
19+
import { t } from 'i18next'
1720

1821
// import { HighlightParser } from './parse_highlight' // future
1922
// import { InkParser } from './parse_ink' // future
@@ -60,19 +63,18 @@ async function parseAnnotationToPdf(annotation: IAnnotationStore, page: PDFPage,
6063
* @param filename - 下载时使用的文件名
6164
*/
6265
function downloadPdf(data: Uint8Array, filename: string) {
63-
// 获取 PDF 的有效 ArrayBuffer 内容,避免使用共享内存或偏移错误
66+
// 提取安全的 ArrayBuffer
6467
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
65-
// 创建 Blob 并生成 URL 下载链接
68+
// 创建 Blob
6669
// @ts-ignore
6770
const blob = new Blob([arrayBuffer], { type: 'application/pdf' })
68-
const url = URL.createObjectURL(blob)
69-
const link = document.createElement('a')
70-
link.href = url
71-
link.download = filename
72-
document.body.appendChild(link)
73-
link.click()
74-
document.body.removeChild(link)
75-
URL.revokeObjectURL(url)
71+
// 使用 saveAs 下载
72+
saveAs(blob, `${filename}.pdf`)
73+
}
74+
75+
function downloadExcel(data: any, filename: string) {
76+
const buffer = new Blob([data], { type: 'application/octet-stream' })
77+
saveAs(buffer, `${filename}.xlsx`)
7678
}
7779

7880
/**
@@ -107,12 +109,8 @@ async function exportAnnotationsToPdf(PDFViewerApplication: PDFViewerApplication
107109
const response = await fetch(PDFViewerApplication._downloadUrl)
108110
const pdfBytes = await response.arrayBuffer()
109111
const pdfDoc = await PDFDocument.load(pdfBytes)
110-
111112
// ✅ 清除原有的所有批注
112113
clearAllAnnotations(pdfDoc)
113-
114-
console.log(annotations)
115-
116114
// 遍历每一个注解并解析应用到对应页面
117115
for (const ann of annotations) {
118116
const page = pdfDoc.getPages()[ann.pageNumber - 1]
@@ -123,9 +121,199 @@ async function exportAnnotationsToPdf(PDFViewerApplication: PDFViewerApplication
123121
const modifiedPdf = await pdfDoc.save()
124122
// 使用 title + 时间戳作为文件名
125123
const baseName = PDFViewerApplication._title || 'annotated'
126-
const fileName = `${baseName}-${getTimestampString()}.pdf`
124+
const fileName = `${baseName}_${getTimestampString()}`
127125

128126
downloadPdf(modifiedPdf, fileName)
129127
}
130128

131-
export { exportAnnotationsToPdf }
129+
async function exportAnnotationsToExcel(PDFViewerApplication: PDFViewerApplication, annotations: IAnnotationStore[]) {
130+
const rows: any[] = []
131+
// 先按页码升序,再按批注时间降序
132+
annotations.sort((a, b) => {
133+
if (a.pageNumber !== b.pageNumber) {
134+
return a.pageNumber - b.pageNumber
135+
}
136+
return getPDFDateTimestamp(b.date) - getPDFDateTimestamp(a.date)
137+
})
138+
const getLastStatusName = (annotation: IAnnotationStore): string => {
139+
const lastWithStatus = [...(annotation.comments || [])].reverse().find(c => c.status !== undefined && c.status !== null)
140+
141+
const status = lastWithStatus?.status ?? CommentStatus.None
142+
return t(`comment.status.${status.toLowerCase()}`)
143+
}
144+
145+
let mainIndex = 1 // 主批注序号
146+
let replyCounter: number = 0 // 回复计数器(每次主批注开始重置)
147+
148+
annotations.forEach(annotation => {
149+
const annotationName = annotationDefinitions.find(def => def.type === annotation.type)?.name
150+
const typeLabel = t(`annotations.${annotationName}`)
151+
// 主批注行
152+
rows.push({
153+
index: `${mainIndex}`,
154+
id: annotation.id,
155+
page: annotation.pageNumber,
156+
annotationType: typeLabel,
157+
recordType: t('export.recordType.annotation'),
158+
author: annotation.title,
159+
content: annotation.contentsObj?.text || '',
160+
date: formatPDFDate(annotation.date, true),
161+
status: getLastStatusName(annotation)
162+
})
163+
// 重置回复计数器
164+
replyCounter = 0
165+
// 回复行
166+
annotation.comments.forEach(comment => {
167+
replyCounter++
168+
rows.push({
169+
index: `${mainIndex}.${replyCounter}`,
170+
id: comment.id,
171+
page: '',
172+
annotationType: '--',
173+
recordType: t('export.recordType.reply'),
174+
author: comment.title,
175+
content: comment.content,
176+
date: formatPDFDate(comment.date, true),
177+
status: ''
178+
})
179+
})
180+
mainIndex++
181+
})
182+
183+
// 创建 workbook 和 sheet
184+
const workbook = new ExcelJS.Workbook()
185+
const sheet = workbook.addWorksheet(t('export.sheetName'))
186+
187+
// 自定义列宽(单位为字符宽度)
188+
sheet.columns = [
189+
{
190+
key: 'index',
191+
header: '#',
192+
width: 8,
193+
style: {
194+
alignment: { vertical: 'middle' }
195+
}
196+
},
197+
{
198+
key: 'id',
199+
header: t('export.fields.id'),
200+
width: 20,
201+
style: {
202+
alignment: {
203+
vertical: 'middle'
204+
}
205+
}
206+
},
207+
{
208+
key: 'page',
209+
header: t('export.fields.page'),
210+
width: 10,
211+
style: {
212+
alignment: {
213+
vertical: 'middle'
214+
}
215+
}
216+
},
217+
{
218+
key: 'annotationType',
219+
header: t('export.fields.annotationType'),
220+
width: 18,
221+
style: {
222+
alignment: {
223+
vertical: 'middle'
224+
}
225+
}
226+
},
227+
{
228+
key: 'recordType',
229+
header: t('export.fields.recordType'),
230+
width: 12,
231+
style: {
232+
alignment: {
233+
vertical: 'middle'
234+
}
235+
}
236+
},
237+
{
238+
key: 'author',
239+
header: t('export.fields.author'),
240+
width: 16,
241+
style: {
242+
alignment: {
243+
vertical: 'middle'
244+
}
245+
}
246+
},
247+
{
248+
key: 'content',
249+
header: t('export.fields.content'),
250+
width: 40,
251+
style: {
252+
alignment: {
253+
wrapText: true,
254+
vertical: 'top'
255+
}
256+
}
257+
},
258+
{
259+
key: 'date',
260+
header: t('export.fields.date'),
261+
width: 22,
262+
style: {
263+
alignment: {
264+
vertical: 'middle'
265+
}
266+
}
267+
},
268+
{
269+
key: 'status',
270+
header: t('export.fields.status'),
271+
width: 14,
272+
style: {
273+
alignment: {
274+
vertical: 'middle'
275+
}
276+
}
277+
}
278+
]
279+
280+
// 写入数据 + 样式
281+
rows.forEach(row => {
282+
const addedRow = sheet.addRow(row)
283+
const isReply = row.recordType === t('export.recordType.reply')
284+
addedRow.font = {
285+
size: 12,
286+
color: { argb: isReply ? '389e0d' : '000000' }
287+
}
288+
})
289+
290+
// 表头样式
291+
sheet.getRow(1).eachCell(cell => {
292+
cell.font = { bold: true, size: 12 }
293+
cell.fill = {
294+
type: 'pattern',
295+
pattern: 'solid',
296+
fgColor: { argb: 'D9E1F2' }
297+
}
298+
})
299+
300+
// 写入数据后给所有单元格加边框
301+
sheet.eachRow(row => {
302+
row.eachCell(cell => {
303+
cell.border = {
304+
top: { style: 'thin', color: { argb: '000000' } },
305+
left: { style: 'thin', color: { argb: '000000' } },
306+
bottom: { style: 'thin', color: { argb: '000000' } },
307+
right: { style: 'thin', color: { argb: '000000' } }
308+
}
309+
})
310+
})
311+
312+
// 导出
313+
const buffer = await workbook.xlsx.writeBuffer()
314+
const baseName = PDFViewerApplication._title || 'annotated'
315+
const fileName = `${baseName}_${getTimestampString()}`
316+
downloadExcel(buffer, fileName)
317+
}
318+
319+
export { exportAnnotationsToPdf, exportAnnotationsToExcel }

0 commit comments

Comments
 (0)