diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index b8cec909..79698f9a 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -88,11 +88,12 @@ export type PdfMeeting = { startTime: string | null; endTime: string | null; location: string | null; - // Legacy field; ignored by renderer (use rowColor instead). isHighlighted?: number; - // Palette key from ROW_COLOR_FILL; unknown/null = no tint. rowColor?: string | null; attendees: PdfMeetingAttendee[]; + mergeStartColumn?: string | null; + mergeEndColumn?: string | null; + mergeText?: string | null; }; export type RenderPdfInput = { @@ -483,10 +484,65 @@ type DrawOpts = { lineHeight?: number; }; -// Draw a single line of text using mixed Arabic/Latin runs (in visual -// order). We measure each run with the right font and lay them out -// left-to-right starting from the resolved alignment. Returns the height -// consumed by this line (including a small line-gap). +const BIDI_CONTROL_RE = /[\u200E\u200F\u200B\u202A-\u202E\u2066-\u2069]/g; +function stripBidiControls(s: string): string { + return s.replace(BIDI_CONTROL_RE, ""); +} + +function measureBidiLineWidth( + doc: PDFKit.PDFDocument, + text: string, + opts: Pick, +): number { + const runs = visualOrderRuns(text, opts.baseDirection); + let w = 0; + for (const r of runs) { + const clean = stripBidiControls(r.text); + if (!clean) continue; + const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping); + const features = featuresFor(r.isArabic); + doc.font(fk).fontSize(opts.fontSize); + w += doc.widthOfString(clean, features ? { features } : {}); + } + return w; +} + +function drawBidiLine( + doc: PDFKit.PDFDocument, + text: string, + opts: DrawOpts, +): void { + const runs = visualOrderRuns(text, opts.baseDirection); + if (runs.length === 0) return; + let totalWidth = 0; + const measured: { clean: string; isArabic: boolean; width: number; fontKey: string; features: PDFKit.Mixins.OpenTypeFeatures[] | undefined }[] = []; + for (const r of runs) { + const clean = stripBidiControls(r.text); + if (!clean) continue; + const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping); + const features = featuresFor(r.isArabic); + doc.font(fk).fontSize(opts.fontSize); + const w = doc.widthOfString(clean, features ? { features } : {}); + totalWidth += w; + measured.push({ clean, isArabic: r.isArabic, width: w, fontKey: fk, features }); + } + let cursorX = opts.x; + if (opts.align === "center") { + cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2); + } else if (opts.align === "right") { + cursorX = opts.x + Math.max(0, opts.width - totalWidth); + } + for (const r of measured) { + doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); + doc.text(r.clean, cursorX, opts.y, { + lineBreak: false, + width: r.width + 1, + ...(r.features ? { features: [...r.features] } : {}), + }); + cursorX += r.width; + } +} + function drawMixedLine( doc: PDFKit.PDFDocument, text: string, @@ -497,8 +553,6 @@ function drawMixedLine( if (runs.length === 0) { return lh; } - // Measure full line width — feature flags must match the draw call so - // ligatures + RTL alternates produce the same advance width. let totalWidth = 0; const measured = runs.map((r) => { const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping); @@ -508,19 +562,15 @@ function drawMixedLine( totalWidth += w; return { ...r, width: w, fontKey: fk, features }; }); - const overflow = totalWidth - opts.width; + if (totalWidth > opts.width + 4) { + return drawWrappingLine(doc, text, opts); + } let cursorX = opts.x; if (opts.align === "center") { cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2); } else if (opts.align === "right") { cursorX = opts.x + Math.max(0, opts.width - totalWidth); } - // If we overflow horizontally, fall back to wrapping via PDFKit's own - // text engine on the first run. Cells in the schedule rarely overflow - // because column widths are generous, but Arabic titles can be long. - if (overflow > 4) { - return drawWrappingLine(doc, text, opts); - } for (const r of measured) { doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); doc.text(r.text, cursorX, opts.y, { @@ -538,32 +588,60 @@ function drawWrappingLine( text: string, opts: DrawOpts, ): number { - // Determine the dominant script for the wrapping pass. Without true - // bidi line-breaking we pick the "majority" script font to wrap with; - // this keeps shaping intact for the dominant direction at the cost of - // a slight metric mismatch on the minority runs. - const arabicChars = [...text].filter(isArabicChar).length; - const dominantArabic = arabicChars * 2 > text.length; - const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping); - doc.font(fk).fontSize(opts.fontSize).fillColor(opts.color ?? "black"); - // Hand the RAW (logical-order) string to pdfkit and let fontkit do - // both Arabic shaping and intra-line RTL reorder via the `features` - // option. Pre-shaping here would double-process the string and - // produce visually-mirrored output (the bug we shipped previously). - const features = featuresFor(dominantArabic); - const before = doc.y; - doc.text(text, opts.x, opts.y, { - width: opts.width, - align: opts.align, - lineBreak: true, - // Tighten the inter-line gap pdfkit applies inside wrapped paragraphs. - // Default is the font's natural lineGap; setting -1 saves ~1pt per - // wrapped line which matters when fitting a full day on one page. - lineGap: -1, - ...(features ? { features } : {}), - }); - const wrapLh = opts.lineHeight ?? opts.fontSize * 1.2; - return Math.max(wrapLh, doc.y - before); + const lh = opts.lineHeight ?? opts.fontSize * 1.2; + if (!text.trim()) return lh; + const words = text.split(/\s+/).filter((w) => w.length > 0); + if (words.length === 0) return lh; + const mOpts = { + fontSize: opts.fontSize, + weight: opts.weight, + baseDirection: opts.baseDirection, + mapping: opts.mapping, + }; + const hardBreak = (word: string): string[] => { + const ww = measureBidiLineWidth(doc, word, mOpts); + if (ww <= opts.width) return [word]; + const chars = [...word]; + const pieces: string[] = []; + let cur = ""; + for (const ch of chars) { + const test = cur + ch; + if (cur && measureBidiLineWidth(doc, test, mOpts) > opts.width) { + pieces.push(cur); + cur = ch; + } else { + cur = test; + } + } + if (cur) pieces.push(cur); + return pieces; + }; + + const visualLines: string[] = []; + let current = ""; + for (let i = 0; i < words.length; i++) { + const ww = measureBidiLineWidth(doc, words[i], mOpts); + if (ww > opts.width) { + if (current) { visualLines.push(current); current = ""; } + for (const piece of hardBreak(words[i])) visualLines.push(piece); + continue; + } + const test = current ? current + " " + words[i] : words[i]; + if (measureBidiLineWidth(doc, test, mOpts) > opts.width) { + visualLines.push(current); + current = words[i]; + } else { + current = test; + } + } + if (current) visualLines.push(current); + if (visualLines.length === 0) return lh; + let totalH = 0; + for (const vl of visualLines) { + drawBidiLine(doc, vl, { ...opts, y: opts.y + totalH }); + totalH += lh; + } + return Math.max(lh, totalH); } export async function renderSchedulePdf(input: RenderPdfInput): Promise { @@ -757,9 +835,13 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise const headerHeight = drawHeader(); doc.y = startY + headerHeight; + const MERGE_COL_INDEX: Record = { + number: 0, meeting: 1, attendees: 2, time: 3, + }; + // ---- Rows ---- for (const meeting of input.meetings) { - const cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [ + let cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [ { text: String(meeting.dailyNumber), align: "center" }, { text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) + @@ -771,28 +853,31 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise meeting.attendees.length === 0 ? "—" : (() => { + const ATTENDEES_PER_LINE = 3; let personIdx = 0; const parts: string[] = []; let currentGroup: string[] = []; + const flushGroup = () => { + if (currentGroup.length === 0) return; + for (let i = 0; i < currentGroup.length; i += ATTENDEES_PER_LINE) { + parts.push(currentGroup.slice(i, i + ATTENDEES_PER_LINE).join(" ")); + } + currentGroup = []; + }; for (const a of meeting.attendees) { const isSub = (a.kind ?? "person") === "subheading"; const name = htmlToPlain(a.name); if (isSub) { - if (currentGroup.length > 0) { - parts.push(currentGroup.join(" ")); - } + flushGroup(); personIdx = 0; parts.push(`— ${name} —`); - currentGroup = []; } else { personIdx += 1; const t = a.title?.trim(); - currentGroup.push(`-${personIdx} ${name}${t ? ` (${t})` : ""}`); + currentGroup.push(`\u202A${personIdx}-\u202C ${name}${t ? ` (${t})` : ""}`); } } - if (currentGroup.length > 0) { - parts.push(currentGroup.join(" ")); - } + flushGroup(); return parts.join("\n"); })(), align: "center", @@ -802,8 +887,29 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise align: "center", }, ]; + let cellWidths = [...colWidths]; + + if (meeting.mergeStartColumn && meeting.mergeEndColumn) { + const startIdx = MERGE_COL_INDEX[meeting.mergeStartColumn]; + const endIdx = MERGE_COL_INDEX[meeting.mergeEndColumn]; + if (startIdx !== undefined && endIdx !== undefined && startIdx <= endIdx) { + const mergedWidth = cellWidths.slice(startIdx, endIdx + 1).reduce((a, b) => a + b, 0); + const mergedCell = { text: meeting.mergeText ? htmlToPlain(meeting.mergeText) : "", align: "center" as const }; + cellsLogical = [ + ...cellsLogical.slice(0, startIdx), + mergedCell, + ...cellsLogical.slice(endIdx + 1), + ]; + cellWidths = [ + ...cellWidths.slice(0, startIdx), + mergedWidth, + ...cellWidths.slice(endIdx + 1), + ]; + } + } + const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical; - const widths = isRtl ? [...colWidths].reverse() : colWidths; + const widths = isRtl ? [...cellWidths].reverse() : cellWidths; // Measure the row height by laying out each cell to a temporary // y-cursor and taking the max. We then redraw at the final position. @@ -812,16 +918,16 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise let rowHeight = lineHeight + cellPadY * 2; const cellHeights: number[] = []; let cx = tableX; + const probeWrapOpts = { + fontSize: input.font.fontSize, + weight: input.font.fontWeight, + baseDirection: baseDir, + mapping, + }; for (let i = 0; i < cells.length; i++) { const c = cells[i]; const startProbeY = probeY + cellPadY; probe.y = startProbeY; - // Use PDFKit's own layout engine (heightOfString) so the probe - // reserves EXACTLY as much vertical space as the eventual draw - // call will consume — passing the same font, features, width, - // and align as drawWrappingLine. A heuristic ceil(w/availableW) - // probe under-counts whenever pdfkit's word-break engine wraps - // sooner than a simple division would, causing rows to overlap. const lines = c.text.split("\n"); let h = 0; const cellW = widths[i] - cellPadX * 2; @@ -830,19 +936,43 @@ export async function renderSchedulePdf(input: RenderPdfInput): Promise h += lineHeight; continue; } - const lineArabicChars = [...line].filter(isArabicChar).length; - const lineDominantArabic = lineArabicChars * 2 > line.length; - const lineFk = fontKeyFor(lineDominantArabic, input.font.fontWeight, mapping); - const lineFeatures = featuresFor(lineDominantArabic); - doc.font(lineFk).fontSize(input.font.fontSize); - const measured = doc.heightOfString(line, { - width: cellW, - align: c.align, - lineBreak: true, - lineGap: -1, - ...(lineFeatures ? { features: lineFeatures } : {}), - }); - h += Math.max(lineHeight, measured); + const singleW = measureBidiLineWidth(doc, line, probeWrapOpts); + if (singleW <= cellW) { + h += lineHeight; + } else { + const wds = line.split(/\s+/).filter((w: string) => w.length > 0); + if (wds.length === 0) { h += lineHeight; continue; } + let wrapCount = 0; + let cur = ""; + for (let wi = 0; wi < wds.length; wi++) { + const wordW = measureBidiLineWidth(doc, wds[wi], probeWrapOpts); + if (wordW > cellW) { + if (cur) { wrapCount++; cur = ""; } + const chars = [...wds[wi]]; + let piece = ""; + for (const ch of chars) { + const test = piece + ch; + if (piece && measureBidiLineWidth(doc, test, probeWrapOpts) > cellW) { + wrapCount++; + piece = ch; + } else { + piece = test; + } + } + if (piece) { wrapCount++; } + continue; + } + const test = cur ? cur + " " + wds[wi] : wds[wi]; + if (measureBidiLineWidth(doc, test, probeWrapOpts) > cellW) { + wrapCount++; + cur = wds[wi]; + } else { + cur = test; + } + } + if (cur) wrapCount++; + h += Math.max(1, wrapCount) * lineHeight; + } } const finalH = h + cellPadY * 2; cellHeights.push(finalH); diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index e65a6d63..1a588e89 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -2686,14 +2686,14 @@ router.get( startTime: m.startTime, endTime: m.endTime, location: m.location, - // Forward saved tint; isHighlighted is intentionally dropped. rowColor: m.rowColor, + mergeStartColumn: m.mergeStartColumn, + mergeEndColumn: m.mergeEndColumn, + mergeText: m.mergeText, attendees: (attendeesByMeeting.get(m.id) ?? []).map((a) => ({ name: a.name, title: a.title, attendanceType: a.attendanceType, - // Forward the row kind so the renderer can render subheadings - // as labels rather than numbered attendees. kind: a.kind, })), })), diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 34569b42..173a27b5 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ diff --git a/attached_assets/Pasted--PDF-PDF--1777885005658_1777885005659.txt b/attached_assets/Pasted--PDF-PDF--1777885005658_1777885005659.txt new file mode 100644 index 00000000..7b7afa1b --- /dev/null +++ b/attached_assets/Pasted--PDF-PDF--1777885005658_1777885005659.txt @@ -0,0 +1,151 @@ +أريد إصلاح تصدير PDF لجدول الاجتماعات بشكل نهائي. + +⚠️ مهم: +التصميم الخارجي للـPDF ثابت (العنوان + الفوتر + حجم الصفحة + الخط DIN Next LT Arabic). +التعديل فقط داخل الجدول. + +================================== +أولاً: إصلاح المشاكل الحالية +================================== + +1. لا يوجد أي فراغات بين الصفوف: +- لا padding عمودي +- لا margin +- ارتفاع الصف = حجم المحتوى فقط + +2. كل اجتماع يجب أن يكون صف واحد: +- الرقم + اسم الاجتماع + الحضور + الوقت في نفس الصف +- لا يظهر الوقت في صف منفصل أبداً + +3. إصلاح الترقيم: +- لا يبدأ من 4 أو رقم خاطئ +- استخدم رقم الاجتماع الحقيقي من البيانات + +4. إصلاح ترتيب الحضور: +- لا يظهر بشكل مقلوب مثل: + -5 سالم -4 علي +- الترتيب يكون: + 1- محمد 2- علي 3- طارق + +5. الدمج: +- أي merge في النظام (rowSpan / colSpan) يجب أن يظهر نفسه في PDF +- لا تعيد بناء الجدول بدون الدمج + +================================== +ثانياً: تنسيق الحضور +================================== + +- الحضور في نفس السطر (inline) +- كل سطر يحتوي 3 إلى 4 أسماء تقريباً +- إذا زاد العدد → ينزل سطر جديد تلقائياً +- لا تجعل كل اسم في سطر مستقل + +مثال: +1- محمد 2- علي 3- طارق +4- سالم 5- عبدالله + +================================== +ثالثاً: محاذاة المحتوى +================================== + +- عمود "الاجتماع" في المنتصف (center) +- عمود "الحضور" في المنتصف +- عمود "الوقت" في المنتصف +- vertical-align: middle + +================================== +رابعاً: عكس تنسيق المستخدم (مهم جداً) +================================== + +داخل الجدول فقط: + +- Bold → يظهر Bold في PDF +- Center / Right / Left → يظهر نفس المحاذاة +- لون الخط → يظهر نفسه +- لون الخلفية → يظهر نفسه +- حجم الخط → يظهر نفسه + +⚠️ لا تجعل CSS العام يلغي تنسيقات الخلايا + +الحل: +- استخدم inline style لكل خلية +- مثال: + + + المحتوى + + +================================== +خامساً: CSS المطلوب +================================== + +@media print { + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + border-spacing: 0; + } + + th, td { + padding: 0 4px !important; + margin: 0 !important; + line-height: 1.05 !important; + vertical-align: middle !important; + text-align: center; + } + + .attendees-inline { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 0 10px; + direction: rtl; + } + + .attendee-item { + white-space: nowrap; + unicode-bidi: isolate; + } + + .time-cell { + direction: ltr; + white-space: nowrap; + text-align: center; + } +} + +================================== +سادساً: شرط مهم جداً +================================== + +لا تبنِ PDF من DOM مكسور. + +- استخدم نفس بيانات الجدول في النظام +- طبّق rowSpan و colSpan كما هي +- كل صف في PDF = صف حقيقي في النظام + +================================== +النتيجة المطلوبة: +================================== + +PDF مطابق للنظام من حيث: +- بدون فراغات +- الحضور inline +- الترقيم صحيح +- الدمج صحيح +- الوقت داخل الصف +- المحاذاة في المنتصف +- جميع التنسيقات (Bold / Color / Alignment) تنعكس \ No newline at end of file