@studious-lms/server 1.1.13 → 1.1.15
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/dist/index.js +3 -0
- package/dist/lib/jsonConversion.d.ts +3 -0
- package/dist/lib/jsonConversion.d.ts.map +1 -0
- package/dist/lib/jsonConversion.js +517 -0
- package/dist/lib/jsonStyles.d.ts +29 -0
- package/dist/lib/jsonStyles.d.ts.map +1 -0
- package/dist/lib/jsonStyles.js +28 -0
- package/dist/routers/_app.d.ts +82 -4
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +30 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +13 -3
- package/dist/routers/conversation.d.ts +1 -1
- package/dist/routers/file.d.ts +4 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +1 -1
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +159 -7
- package/dist/routers/message.d.ts +5 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +18 -5
- package/dist/utils/inference.d.ts +5 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +16 -0
- package/package.json +2 -1
- package/prisma/schema.prisma +6 -0
- package/src/index.ts +4 -0
- package/src/lib/jsonConversion.ts +537 -0
- package/src/lib/jsonStyles.ts +36 -0
- package/src/routers/class.ts +14 -3
- package/src/routers/labChat.ts +166 -7
- package/src/routers/message.ts +18 -5
- package/src/utils/inference.ts +19 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { PDFDocument, PDFFont, RGB, StandardFonts, last, rgb } from 'pdf-lib'
|
|
2
|
+
import { writeFile } from 'fs'
|
|
3
|
+
import { DocumentBlock, FormatTypes, Fonts } from './jsonStyles'
|
|
4
|
+
|
|
5
|
+
export async function createPdf(blocks: DocumentBlock[]) {
|
|
6
|
+
console.log('createPdf: Starting PDF creation with', blocks.length, 'blocks');
|
|
7
|
+
try {
|
|
8
|
+
const pdfDoc = await PDFDocument.create()
|
|
9
|
+
console.log('createPdf: PDFDocument created successfully');
|
|
10
|
+
const timesRomanFont = await pdfDoc.embedFont(StandardFonts.TimesRoman)
|
|
11
|
+
const courierFont = await pdfDoc.embedFont(StandardFonts.Courier)
|
|
12
|
+
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
|
13
|
+
const helveticaBoldFont = await pdfDoc.embedFont(StandardFonts.HelveticaBold)
|
|
14
|
+
const helveticaItalicFont = await pdfDoc.embedFont(StandardFonts.HelveticaOblique)
|
|
15
|
+
const helveticaBoldItalicFont = await pdfDoc.embedFont(StandardFonts.HelveticaBoldOblique)
|
|
16
|
+
|
|
17
|
+
const defaultFont = helveticaFont
|
|
18
|
+
const defaultParagraphSpacing = 10;
|
|
19
|
+
const defaultLineHeight = 1.3
|
|
20
|
+
const defaultFontSize = 12
|
|
21
|
+
const defaultIndentWidth = 14
|
|
22
|
+
const defaultPadding = 10
|
|
23
|
+
|
|
24
|
+
const headingColor = rgb(0.1, 0.1, 0.1)
|
|
25
|
+
const paragraphColor = rgb(0.15, 0.15, 0.15)
|
|
26
|
+
|
|
27
|
+
const FONTS: Record<number, PDFFont> = {
|
|
28
|
+
[Fonts.TIMES_ROMAN]: timesRomanFont,
|
|
29
|
+
[Fonts.COURIER]: courierFont,
|
|
30
|
+
[Fonts.HELVETICA]: helveticaFont,
|
|
31
|
+
[Fonts.HELVETICA_BOLD]: helveticaBoldFont,
|
|
32
|
+
[Fonts.HELVETICA_ITALIC]: helveticaItalicFont,
|
|
33
|
+
[Fonts.HELVETICA_BOLD_ITALIC]: helveticaBoldItalicFont,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STYLE_PRESETS: Record<number,
|
|
37
|
+
{ fontSize: number; lineHeight: number; paragraphSpacing?: number; font?: PDFFont; color?: RGB; background?: RGB }> =
|
|
38
|
+
{
|
|
39
|
+
[FormatTypes.HEADER_1]: { fontSize: 28, lineHeight: 1.35, font: helveticaBoldFont, color: headingColor },
|
|
40
|
+
[FormatTypes.HEADER_2]: { fontSize: 22, lineHeight: 1.35, font: helveticaBoldFont, color: headingColor },
|
|
41
|
+
[FormatTypes.HEADER_3]: { fontSize: 18, lineHeight: 1.35, font: helveticaBoldFont, color: headingColor },
|
|
42
|
+
[FormatTypes.HEADER_4]: { fontSize: 16, lineHeight: 1.3, font: helveticaBoldFont, color: headingColor },
|
|
43
|
+
[FormatTypes.HEADER_5]: { fontSize: 14, lineHeight: 1.3, font: helveticaBoldFont, color: headingColor },
|
|
44
|
+
[FormatTypes.HEADER_6]: { fontSize: 12, lineHeight: 1.3, font: helveticaBoldFont, color: headingColor },
|
|
45
|
+
[FormatTypes.QUOTE]: { fontSize: 14, lineHeight: 1.5, color: rgb(0.35, 0.35, 0.35) },
|
|
46
|
+
[FormatTypes.CODE_BLOCK]: { fontSize: 12, lineHeight: 1.6, font: courierFont, color: rgb(0.1, 0.1, 0.1), background: rgb(0.95, 0.95, 0.95) },
|
|
47
|
+
[FormatTypes.PARAGRAPH]: { fontSize: 12, lineHeight: 1.3, color: paragraphColor },
|
|
48
|
+
[FormatTypes.BULLET]: { fontSize: 12, lineHeight: 1.3, color: paragraphColor },
|
|
49
|
+
[FormatTypes.NUMBERED]: { fontSize: 12, lineHeight: 1.3, color: paragraphColor },
|
|
50
|
+
[FormatTypes.TABLE]: { fontSize: 12, lineHeight: 1.3, color: paragraphColor },
|
|
51
|
+
[FormatTypes.IMAGE]: { fontSize: 12, lineHeight: 1.3 },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hexToRgb = (hex) => {
|
|
55
|
+
if (hex.length == 7) {
|
|
56
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255.0;
|
|
57
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255.0;
|
|
58
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255.0;
|
|
59
|
+
return rgb(r, g, b);
|
|
60
|
+
} else if (hex.length == 4) {
|
|
61
|
+
const r = parseInt(hex.slice(1, 2), 16) / 15.0;
|
|
62
|
+
const g = parseInt(hex.slice(2, 3), 16) / 15.0;
|
|
63
|
+
const b = parseInt(hex.slice(3, 4), 16) / 15.0;
|
|
64
|
+
return rgb(r, g, b);
|
|
65
|
+
} else { return rgb(0.0, 0.0, 0.0) };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const colorParse = (color) => {
|
|
69
|
+
if (typeof color === 'string') {
|
|
70
|
+
return hexToRgb(color)
|
|
71
|
+
} else {
|
|
72
|
+
return color
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Function to replace Unicode characters that can't be encoded by WinAnsi
|
|
77
|
+
const sanitizeText = (text: string): string => {
|
|
78
|
+
return text
|
|
79
|
+
.replace(/→/g, '->') // Right arrow
|
|
80
|
+
.replace(/←/g, '<-') // Left arrow
|
|
81
|
+
.replace(/↑/g, '^') // Up arrow
|
|
82
|
+
.replace(/↓/g, 'v') // Down arrow
|
|
83
|
+
.replace(/•/g, '*') // Bullet point
|
|
84
|
+
.replace(/–/g, '-') // En dash
|
|
85
|
+
.replace(/—/g, '--') // Em dash
|
|
86
|
+
.replace(/'/g, "'") // Left single quote
|
|
87
|
+
.replace(/'/g, "'") // Right single quote
|
|
88
|
+
.replace(/"/g, '"') // Left double quote
|
|
89
|
+
.replace(/"/g, '"') // Right double quote
|
|
90
|
+
.replace(/…/g, '...') // Ellipsis
|
|
91
|
+
.replace(/°/g, ' degrees') // Degree symbol
|
|
92
|
+
.replace(/±/g, '+/-') // Plus-minus
|
|
93
|
+
.replace(/×/g, 'x') // Multiplication sign
|
|
94
|
+
.replace(/÷/g, '/') // Division sign
|
|
95
|
+
// Add more replacements as needed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let page = pdfDoc.addPage()
|
|
99
|
+
let { width, height } = page.getSize()
|
|
100
|
+
const { marginTop, marginBottom, marginLeft, marginRight } = { marginTop: 50, marginBottom: 50, marginLeft: 50, marginRight: 50 }
|
|
101
|
+
|
|
102
|
+
const maxTextWidth = () => width - marginLeft - marginRight
|
|
103
|
+
|
|
104
|
+
const wrapText = (text: string, font: any, fontSize: number, maxWidth: number): string[] => {
|
|
105
|
+
if (!text) return ['']
|
|
106
|
+
const words = text.split(/\s+/)
|
|
107
|
+
const lines: string[] = []
|
|
108
|
+
let current = ''
|
|
109
|
+
|
|
110
|
+
const measure = (t: string) => font.widthOfTextAtSize(t, fontSize)
|
|
111
|
+
|
|
112
|
+
const pushCurrent = () => {
|
|
113
|
+
if (current.length > 0) {
|
|
114
|
+
lines.push(current)
|
|
115
|
+
current = ''
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < words.length; i++) {
|
|
120
|
+
const word = words[i]
|
|
121
|
+
if (current.length === 0) {
|
|
122
|
+
// If a single word is too long, hard-break it
|
|
123
|
+
if (measure(word) > maxWidth) {
|
|
124
|
+
let chunk = ''
|
|
125
|
+
for (const ch of word) {
|
|
126
|
+
const test = chunk + ch
|
|
127
|
+
if (measure(test) > maxWidth && chunk.length > 0) {
|
|
128
|
+
lines.push(chunk)
|
|
129
|
+
chunk = ch
|
|
130
|
+
} else {
|
|
131
|
+
chunk = test
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
current = chunk
|
|
135
|
+
} else {
|
|
136
|
+
current = word
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const test = current + ' ' + word
|
|
140
|
+
if (measure(test) <= maxWidth) {
|
|
141
|
+
current = test
|
|
142
|
+
} else {
|
|
143
|
+
pushCurrent()
|
|
144
|
+
// start new line with this word; hard-break if needed
|
|
145
|
+
if (measure(word) > maxWidth) {
|
|
146
|
+
let chunk = ''
|
|
147
|
+
for (const ch of word) {
|
|
148
|
+
const t2 = chunk + ch
|
|
149
|
+
if (measure(t2) > maxWidth && chunk.length > 0) {
|
|
150
|
+
lines.push(chunk)
|
|
151
|
+
chunk = ch
|
|
152
|
+
} else {
|
|
153
|
+
chunk = t2
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
current = chunk
|
|
157
|
+
} else {
|
|
158
|
+
current = word
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
pushCurrent()
|
|
164
|
+
return lines
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let y = height - marginTop
|
|
168
|
+
let lastLineHeight = -1
|
|
169
|
+
console.log('createPdf: Starting to process', blocks.length, 'blocks');
|
|
170
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
171
|
+
const block = blocks[i];
|
|
172
|
+
console.log(`createPdf: Processing block ${i + 1}/${blocks.length}, format: ${block.format}, content type: ${typeof block.content}`);
|
|
173
|
+
try {
|
|
174
|
+
const preset = STYLE_PRESETS[block.format] || { fontSize: defaultFontSize, lineHeight: defaultLineHeight }
|
|
175
|
+
|
|
176
|
+
const userLineHeight = (block as any).metadata?.lineHeight
|
|
177
|
+
|
|
178
|
+
const fontSize = (block as any).metadata?.fontSize || preset.fontSize
|
|
179
|
+
const lineHeight = (userLineHeight ? fontSize * userLineHeight : fontSize * preset.lineHeight)
|
|
180
|
+
const paragraphSpacing = (block as any).metadata?.paragraphSpacing || defaultParagraphSpacing
|
|
181
|
+
const indentWidth = (block as any).metadata?.indentWidth || defaultIndentWidth
|
|
182
|
+
|
|
183
|
+
const paddingX = (block as any).metadata?.paddingX || defaultPadding
|
|
184
|
+
const paddingY = (block as any).metadata?.paddingY || defaultPadding
|
|
185
|
+
|
|
186
|
+
const font = FONTS[(block as any).metadata?.font] || preset.font || defaultFont // Broken
|
|
187
|
+
const color = colorParse((block as any).metadata?.color || preset.color || rgb(0.0, 0.0, 0.0))
|
|
188
|
+
const background = colorParse((block as any).metadata?.background || preset.background || rgb(1.0, 1.0, 1.0))
|
|
189
|
+
|
|
190
|
+
if (lastLineHeight >= 0) {
|
|
191
|
+
y -= lineHeight - lastLineHeight
|
|
192
|
+
} else {
|
|
193
|
+
y -= fontSize
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ensureSpace = (needed: number) => {
|
|
197
|
+
if (y - needed < marginBottom) {
|
|
198
|
+
page = pdfDoc.addPage()
|
|
199
|
+
; ({ width, height } = page.getSize())
|
|
200
|
+
y = height - marginTop - fontSize
|
|
201
|
+
lastLineHeight = -1
|
|
202
|
+
return true
|
|
203
|
+
} else { return false }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const drawParagraph = (text: string) => {
|
|
207
|
+
const sanitizedText = sanitizeText(text)
|
|
208
|
+
const lines = wrapText(sanitizedText, font, fontSize, maxTextWidth())
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
ensureSpace(lineHeight)
|
|
211
|
+
page.drawText(line, {
|
|
212
|
+
x: marginLeft,
|
|
213
|
+
y: y,
|
|
214
|
+
size: fontSize,
|
|
215
|
+
font: font,
|
|
216
|
+
color: color,
|
|
217
|
+
})
|
|
218
|
+
y -= lineHeight
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const drawHeading = (text: string, align?: 'left' | 'center' | 'right') => {
|
|
223
|
+
const sanitizedText = sanitizeText(text)
|
|
224
|
+
const lines = wrapText(sanitizedText, font, fontSize, maxTextWidth())
|
|
225
|
+
for (const line of lines) {
|
|
226
|
+
ensureSpace(lineHeight)
|
|
227
|
+
let x = marginLeft
|
|
228
|
+
if (align === 'center') {
|
|
229
|
+
const tw = font.widthOfTextAtSize(line, fontSize)
|
|
230
|
+
x = marginLeft + (maxTextWidth() - tw) / 2
|
|
231
|
+
} else if (align === 'right') {
|
|
232
|
+
const tw = font.widthOfTextAtSize(line, fontSize)
|
|
233
|
+
x = marginLeft + maxTextWidth() - tw
|
|
234
|
+
}
|
|
235
|
+
page.drawText(line, {
|
|
236
|
+
x,
|
|
237
|
+
y: y,
|
|
238
|
+
size: fontSize,
|
|
239
|
+
font: font,
|
|
240
|
+
color: color,
|
|
241
|
+
})
|
|
242
|
+
y -= lineHeight
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const drawBulletList = (items: string[]) => {
|
|
247
|
+
const bulletIndent = indentWidth
|
|
248
|
+
const gap = 8
|
|
249
|
+
const contentWidth = maxTextWidth() - (bulletIndent + gap)
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
const sanitizedItem = sanitizeText(item)
|
|
252
|
+
const lines = wrapText(sanitizedItem, font, fontSize, contentWidth)
|
|
253
|
+
ensureSpace(lineHeight)
|
|
254
|
+
// Bullet glyph (use ASCII bullet instead of Unicode)
|
|
255
|
+
page.drawText('*', {
|
|
256
|
+
x: marginLeft + gap,
|
|
257
|
+
y: y,
|
|
258
|
+
size: fontSize,
|
|
259
|
+
font: font,
|
|
260
|
+
color: color,
|
|
261
|
+
})
|
|
262
|
+
// First line
|
|
263
|
+
page.drawText(lines[0], {
|
|
264
|
+
x: marginLeft + bulletIndent + gap,
|
|
265
|
+
y: y,
|
|
266
|
+
size: fontSize,
|
|
267
|
+
font: font,
|
|
268
|
+
color: color,
|
|
269
|
+
})
|
|
270
|
+
y -= lineHeight
|
|
271
|
+
// Continuation lines with hanging indent
|
|
272
|
+
for (let i = 1; i < lines.length; i++) {
|
|
273
|
+
ensureSpace(lineHeight)
|
|
274
|
+
page.drawText(lines[i], {
|
|
275
|
+
x: marginLeft + bulletIndent + gap,
|
|
276
|
+
y: y,
|
|
277
|
+
size: fontSize,
|
|
278
|
+
font: font,
|
|
279
|
+
color: color,
|
|
280
|
+
})
|
|
281
|
+
y -= lineHeight
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const drawNumberedList = (items: string[]) => {
|
|
287
|
+
const numberIndent = indentWidth
|
|
288
|
+
const gap = 8
|
|
289
|
+
const contentWidth = maxTextWidth() - (numberIndent + gap)
|
|
290
|
+
let index = 1
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
const numLabel = `${index}.`
|
|
293
|
+
const numWidth = font.widthOfTextAtSize(numLabel, fontSize)
|
|
294
|
+
const sanitizedItem = sanitizeText(item)
|
|
295
|
+
const lines = wrapText(sanitizedItem, font, fontSize, contentWidth)
|
|
296
|
+
ensureSpace(lineHeight)
|
|
297
|
+
page.drawText(numLabel, {
|
|
298
|
+
x: marginLeft + gap,
|
|
299
|
+
y: y,
|
|
300
|
+
size: fontSize,
|
|
301
|
+
font: font,
|
|
302
|
+
color: color,
|
|
303
|
+
})
|
|
304
|
+
page.drawText(lines[0], {
|
|
305
|
+
x: marginLeft + Math.max(numberIndent, numWidth + 6) + gap,
|
|
306
|
+
y: y,
|
|
307
|
+
size: fontSize,
|
|
308
|
+
font: font,
|
|
309
|
+
color: color,
|
|
310
|
+
})
|
|
311
|
+
y -= lineHeight
|
|
312
|
+
for (let i = 1; i < lines.length; i++) {
|
|
313
|
+
ensureSpace(lineHeight)
|
|
314
|
+
page.drawText(lines[i], {
|
|
315
|
+
x: marginLeft + Math.max(numberIndent, numWidth + 6) + gap,
|
|
316
|
+
y: y,
|
|
317
|
+
size: fontSize,
|
|
318
|
+
font: font,
|
|
319
|
+
color: color,
|
|
320
|
+
})
|
|
321
|
+
y -= lineHeight
|
|
322
|
+
}
|
|
323
|
+
index++
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const drawQuote = (text: string) => {
|
|
328
|
+
const ruleWidth = 2
|
|
329
|
+
const ruleGap = 8
|
|
330
|
+
const contentX = marginLeft + ruleWidth + ruleGap
|
|
331
|
+
const contentWidth = maxTextWidth() - (ruleWidth + ruleGap)
|
|
332
|
+
const sanitizedText = sanitizeText(text)
|
|
333
|
+
const lines = wrapText(sanitizedText, font, fontSize, contentWidth)
|
|
334
|
+
const totalHeight = lines.length * lineHeight + fontSize
|
|
335
|
+
var remainingHeight = totalHeight
|
|
336
|
+
for (const line of lines) {
|
|
337
|
+
let pageAdded = ensureSpace(lineHeight)
|
|
338
|
+
if (pageAdded || remainingHeight == totalHeight) {
|
|
339
|
+
let blockHeight = Math.floor(Math.min(remainingHeight, y - marginBottom) / lineHeight) * lineHeight // Get remaining height on page
|
|
340
|
+
page.drawRectangle({
|
|
341
|
+
x: marginLeft,
|
|
342
|
+
y: y + lineHeight,
|
|
343
|
+
width: ruleWidth,
|
|
344
|
+
height: -blockHeight - lineHeight + fontSize,
|
|
345
|
+
color: color,
|
|
346
|
+
})
|
|
347
|
+
remainingHeight -= blockHeight + lineHeight - fontSize
|
|
348
|
+
}
|
|
349
|
+
page.drawText(line, {
|
|
350
|
+
x: contentX,
|
|
351
|
+
y: y,
|
|
352
|
+
size: fontSize,
|
|
353
|
+
font: font,
|
|
354
|
+
color: color,
|
|
355
|
+
})
|
|
356
|
+
y -= lineHeight
|
|
357
|
+
}
|
|
358
|
+
y -= lineHeight - fontSize
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const drawCodeBlock = (textLines: string[]) => {
|
|
362
|
+
const codeFont = font
|
|
363
|
+
|
|
364
|
+
// Detect indentation patterns
|
|
365
|
+
const detectIndentation = (lines: string[]) => {
|
|
366
|
+
let tabCount = 0
|
|
367
|
+
let spaceCount = 0
|
|
368
|
+
let minSpaces = Infinity
|
|
369
|
+
|
|
370
|
+
for (const line of lines) {
|
|
371
|
+
if (line.trim().length === 0) continue // Skip empty lines
|
|
372
|
+
|
|
373
|
+
const leadingWhitespace = line.match(/^(\s*)/)?.[1] || ''
|
|
374
|
+
if (leadingWhitespace.includes('\t')) {
|
|
375
|
+
tabCount++
|
|
376
|
+
} else if (leadingWhitespace.length > 0) {
|
|
377
|
+
spaceCount++
|
|
378
|
+
minSpaces = Math.min(minSpaces, leadingWhitespace.length)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Determine indentation strategy
|
|
383
|
+
if (tabCount > spaceCount) {
|
|
384
|
+
return { type: 'tab', width: indentWidth }
|
|
385
|
+
} else if (spaceCount > 0) {
|
|
386
|
+
// Use the most common space count, or default to 4
|
|
387
|
+
const commonSpaces = minSpaces === Infinity ? 4 : minSpaces
|
|
388
|
+
return { type: 'space', width: commonSpaces * (fontSize * 0.6) } // Approximate space width
|
|
389
|
+
} else {
|
|
390
|
+
return { type: 'space', width: fontSize * 2.4 } // Default 4 spaces
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const indentInfo = detectIndentation(textLines)
|
|
395
|
+
|
|
396
|
+
// Process lines with indentation
|
|
397
|
+
const processedLines: { text: string; indentLevel: number; originalLine: string }[] = []
|
|
398
|
+
|
|
399
|
+
for (const line of textLines) {
|
|
400
|
+
const leadingWhitespace = line.match(/^(\s*)/)?.[1] || ''
|
|
401
|
+
let indentLevel = 0
|
|
402
|
+
|
|
403
|
+
if (indentInfo.type === 'tab') {
|
|
404
|
+
indentLevel = leadingWhitespace.split('\t').length - 1
|
|
405
|
+
} else {
|
|
406
|
+
// Count spaces, grouping by the detected space width
|
|
407
|
+
const spaceWidth = indentInfo.width / (fontSize * 0.6) // Convert back to space count
|
|
408
|
+
indentLevel = Math.floor(leadingWhitespace.length / spaceWidth)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
processedLines.push({
|
|
412
|
+
text: line.trim(),
|
|
413
|
+
indentLevel,
|
|
414
|
+
originalLine: line
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Wrap each processed line separately to preserve line breaks and indentation
|
|
419
|
+
const wrappedLines: { text: string; indentLevel: number; isContinuation: boolean }[] = []
|
|
420
|
+
const contentW = maxTextWidth() - paddingX * 2
|
|
421
|
+
|
|
422
|
+
for (const processedLine of processedLines) {
|
|
423
|
+
if (processedLine.text.length === 0) {
|
|
424
|
+
// Empty line - preserve as empty line
|
|
425
|
+
wrappedLines.push({
|
|
426
|
+
text: '',
|
|
427
|
+
indentLevel: processedLine.indentLevel,
|
|
428
|
+
isContinuation: false
|
|
429
|
+
})
|
|
430
|
+
} else {
|
|
431
|
+
const parts = wrapText(processedLine.text, codeFont, fontSize, contentW)
|
|
432
|
+
for (let i = 0; i < parts.length; i++) {
|
|
433
|
+
wrappedLines.push({
|
|
434
|
+
text: parts[i],
|
|
435
|
+
indentLevel: processedLine.indentLevel,
|
|
436
|
+
isContinuation: i > 0
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const totalHeight = wrappedLines.length * lineHeight + paddingY * 2
|
|
443
|
+
var remainingHeight = totalHeight
|
|
444
|
+
y -= paddingY
|
|
445
|
+
|
|
446
|
+
for (const wrappedLine of wrappedLines) {
|
|
447
|
+
let pageAdded = ensureSpace(lineHeight + paddingY)
|
|
448
|
+
if (pageAdded || remainingHeight == totalHeight) {
|
|
449
|
+
let blockHeight = Math.floor(Math.min(remainingHeight, y - marginBottom - paddingY) / lineHeight) * lineHeight
|
|
450
|
+
page.drawRectangle({
|
|
451
|
+
x: marginLeft,
|
|
452
|
+
y: y + fontSize,
|
|
453
|
+
width: maxTextWidth(),
|
|
454
|
+
height: -blockHeight - paddingY * 2,
|
|
455
|
+
color: background,
|
|
456
|
+
})
|
|
457
|
+
remainingHeight -= blockHeight
|
|
458
|
+
y -= paddingY
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Calculate indentation offset
|
|
462
|
+
const indentOffset = wrappedLine.indentLevel * indentInfo.width
|
|
463
|
+
|
|
464
|
+
page.drawText(wrappedLine.text, {
|
|
465
|
+
x: marginLeft + paddingX + indentOffset,
|
|
466
|
+
y: y,
|
|
467
|
+
size: fontSize,
|
|
468
|
+
font: codeFont,
|
|
469
|
+
color: color,
|
|
470
|
+
})
|
|
471
|
+
y -= lineHeight
|
|
472
|
+
}
|
|
473
|
+
y -= paddingY
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Render by block format
|
|
477
|
+
switch (block.format) {
|
|
478
|
+
case FormatTypes.HEADER_1:
|
|
479
|
+
case FormatTypes.HEADER_2:
|
|
480
|
+
case FormatTypes.HEADER_3:
|
|
481
|
+
case FormatTypes.HEADER_4:
|
|
482
|
+
case FormatTypes.HEADER_5:
|
|
483
|
+
case FormatTypes.HEADER_6: {
|
|
484
|
+
const align = (block as any).metadata?.align as 'left' | 'center' | 'right' | undefined
|
|
485
|
+
drawHeading(String(block.content), align)
|
|
486
|
+
break
|
|
487
|
+
}
|
|
488
|
+
case FormatTypes.BULLET: {
|
|
489
|
+
const items = Array.isArray(block.content) ? block.content.map(String) : [String(block.content)]
|
|
490
|
+
drawBulletList(items)
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
case FormatTypes.NUMBERED: {
|
|
494
|
+
const items = Array.isArray(block.content) ? block.content.map(String) : [String(block.content)]
|
|
495
|
+
drawNumberedList(items)
|
|
496
|
+
break
|
|
497
|
+
}
|
|
498
|
+
case FormatTypes.QUOTE: {
|
|
499
|
+
drawQuote(String(block.content))
|
|
500
|
+
break
|
|
501
|
+
}
|
|
502
|
+
case FormatTypes.CODE_BLOCK: {
|
|
503
|
+
const lines = Array.isArray(block.content) ? block.content.map(String) : String(block.content).split('\n')
|
|
504
|
+
drawCodeBlock(lines)
|
|
505
|
+
break
|
|
506
|
+
}
|
|
507
|
+
default: {
|
|
508
|
+
if (typeof block.content === 'string') {
|
|
509
|
+
drawParagraph(block.content)
|
|
510
|
+
} else {
|
|
511
|
+
for (const c of block.content) {
|
|
512
|
+
drawParagraph(String(c))
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
console.log(`createPdf: Successfully processed block ${i + 1}`);
|
|
518
|
+
y -= paragraphSpacing
|
|
519
|
+
lastLineHeight = lineHeight
|
|
520
|
+
} catch (blockError) {
|
|
521
|
+
console.error(`createPdf: Error processing block ${i + 1}:`, blockError);
|
|
522
|
+
throw blockError;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
console.log('createPdf: About to save PDF document');
|
|
527
|
+
const pdfBytes = await pdfDoc.save()
|
|
528
|
+
console.log('createPdf: PDF saved successfully, bytes length:', pdfBytes.length);
|
|
529
|
+
// writeFile('output.pdf', pdfBytes, () => {
|
|
530
|
+
// console.log('PDF created successfully') // Still only saves file, no API yet
|
|
531
|
+
// })
|
|
532
|
+
return pdfBytes
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error('createPdf: Error during PDF creation:', error);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// -----------------------------
|
|
2
|
+
// Basic format enums and interfaces
|
|
3
|
+
// -----------------------------
|
|
4
|
+
export enum FormatTypes {
|
|
5
|
+
HEADER_1,
|
|
6
|
+
HEADER_2,
|
|
7
|
+
HEADER_3,
|
|
8
|
+
HEADER_4,
|
|
9
|
+
HEADER_5,
|
|
10
|
+
HEADER_6,
|
|
11
|
+
PARAGRAPH,
|
|
12
|
+
BULLET,
|
|
13
|
+
NUMBERED,
|
|
14
|
+
TABLE,
|
|
15
|
+
IMAGE,
|
|
16
|
+
CODE_BLOCK,
|
|
17
|
+
QUOTE,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export enum Fonts {
|
|
22
|
+
TIMES_ROMAN,
|
|
23
|
+
COURIER,
|
|
24
|
+
HELVETICA,
|
|
25
|
+
HELVETICA_BOLD,
|
|
26
|
+
HELVETICA_ITALIC,
|
|
27
|
+
HELVETICA_BOLD_ITALIC,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
// Each content block in a document
|
|
32
|
+
export interface DocumentBlock {
|
|
33
|
+
format: FormatTypes;
|
|
34
|
+
content: string | string[]; // can be text, list items, etc. string as html / mrkdown
|
|
35
|
+
metadata?: Record<string, any>; // optional extra formatting info (e.g. alignment)
|
|
36
|
+
}
|
package/src/routers/class.ts
CHANGED
|
@@ -89,6 +89,15 @@ export const classRouter = createTRPCRouter({
|
|
|
89
89
|
.query(async ({ ctx, input }) => {
|
|
90
90
|
const { classId } = input;
|
|
91
91
|
|
|
92
|
+
const isTeacher = await prisma.class.findFirst({
|
|
93
|
+
where: {
|
|
94
|
+
id: classId,
|
|
95
|
+
teachers: {
|
|
96
|
+
some: { id: ctx.user?.id },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
92
101
|
const classData = await prisma.class.findUnique({
|
|
93
102
|
where: {
|
|
94
103
|
id: classId,
|
|
@@ -169,9 +178,11 @@ export const classRouter = createTRPCRouter({
|
|
|
169
178
|
},
|
|
170
179
|
},
|
|
171
180
|
submissions: {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
...(!isTeacher && {
|
|
182
|
+
where: {
|
|
183
|
+
studentId: ctx.user?.id,
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
175
186
|
select: {
|
|
176
187
|
studentId: true,
|
|
177
188
|
id: true,
|