@medicus.ai/medicus-report-pdf-generator 1.3.11 → 1.3.13

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.
@@ -24,5 +24,17 @@
24
24
  "body_font_color": "#0F2062",
25
25
  "list_icons_font_color": "#0F2062",
26
26
  "insights_font_color": "#1A3A7A",
27
- "signature": "Team Medicus"
27
+ "signature": "Team Medicus",
28
+ "big_integral_questionnaire": {
29
+ "title": "Key to the Big Integral Questionnaire",
30
+ "showHormonalFemale": true,
31
+ "showHormonalMale": true,
32
+ "colors": {
33
+ "headerBg": "#0F2062",
34
+ "subheaderBg": "#1B4080",
35
+ "headerText": "#FFFFFF",
36
+ "rowBorder": "rgba(15, 32, 98, 0.12)",
37
+ "elevatedText": "#A43B2F"
38
+ }
39
+ }
28
40
  }
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Dummy data — structure mirrors the Excel RESULTS KEY sheet.
5
+ // result = 0 for all rows (questionnaire not yet filled in).
6
+ // Hormonal Health rows have no High/Questions/Maximum — rendered as "—".
7
+ // ---------------------------------------------------------------------------
8
+ function getDummyData() {
9
+ return {
10
+ rows: [
11
+ {
12
+ system: "STOMACH and GI (Gastrointestinal) Tract",
13
+ high: ">8",
14
+ questions: 9,
15
+ result: 0,
16
+ maximum: 27,
17
+ },
18
+ {
19
+ system: "Small Intestine",
20
+ high: ">7",
21
+ questions: 8,
22
+ result: 0,
23
+ maximum: 24,
24
+ },
25
+ {
26
+ system: "Infections/Invasions of the GI (Gastrointestinal Tract)",
27
+ high: ">3",
28
+ questions: 4,
29
+ result: 0,
30
+ maximum: 12,
31
+ },
32
+ {
33
+ system: "Large Intestine",
34
+ high: ">2",
35
+ questions: 3,
36
+ result: 0,
37
+ maximum: 9,
38
+ },
39
+ {
40
+ system: "Liver and Gallbladder",
41
+ high: ">7",
42
+ questions: 8,
43
+ result: 0,
44
+ maximum: 22,
45
+ },
46
+ {
47
+ system: "Essential Nutrients",
48
+ high: ">25",
49
+ questions: 26,
50
+ result: 0,
51
+ maximum: 68,
52
+ },
53
+ {
54
+ system: "Carbohydrate Metabolism",
55
+ high: ">4",
56
+ questions: 4,
57
+ result: 0,
58
+ maximum: 12,
59
+ },
60
+ {
61
+ system: "Immune System",
62
+ high: ">3",
63
+ questions: 4,
64
+ result: 0,
65
+ maximum: 10,
66
+ },
67
+ { system: "Lifestyle", high: ">7", questions: 7, result: 0, maximum: 7 },
68
+ {
69
+ system: "Hormonal Health Female",
70
+ high: null,
71
+ questions: null,
72
+ result: 0,
73
+ maximum: null,
74
+ },
75
+ {
76
+ system: "Hormonal Health Male",
77
+ high: null,
78
+ questions: null,
79
+ result: 0,
80
+ maximum: null,
81
+ },
82
+ ],
83
+ meta: {
84
+ patientName: "Final Test 3",
85
+ age: "37",
86
+ bmi: "23.67",
87
+ },
88
+ };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Helpers
93
+ // ---------------------------------------------------------------------------
94
+ function fmt(val, decimals) {
95
+ if (val === null || val === undefined) return "\u2014"; // em dash
96
+ if (typeof val === "number") {
97
+ const rounded =
98
+ Math.round(val * Math.pow(10, decimals)) / Math.pow(10, decimals);
99
+ return String(rounded);
100
+ }
101
+ return String(val);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Single render — faithful to the reference image, Maison Sante colours.
106
+ //
107
+ // Structure:
108
+ // ┌──────────────────────────────────────────────────────┐
109
+ // │ Key to the Big Integral Questionnaire [title bar] │
110
+ // ├──────────────────────────────────────────────────────┤
111
+ // │ Client/Patient Full Name … Age … BMI … │
112
+ // ├──────────────────────────────────────────────────────┤
113
+ // │ SYSTEM SCORES [subheader band] │
114
+ // ├────────────┬──────┬─────────┬────────┬───────┬──┬───┤
115
+ // │ SYSTEM │ High │Questions│ Result │Maximum│/3 │Cmt│
116
+ // ├────────────┼──────┼─────────┼────────┼───────┼──┼───┤
117
+ // │ ...rows... │
118
+ // └──────────────────────────────────────────────────────┘
119
+ // ---------------------------------------------------------------------------
120
+ function renderTable(rows, meta, cfg) {
121
+ const c = cfg.colors;
122
+ const title = cfg.title || "Key to the Big Integral Questionnaire";
123
+
124
+ // ── patient info bar ──────────────────────────────────────────────────
125
+ const infoBar = `
126
+ <div style="background:#ffffff;border-bottom:1px solid ${c.rowBorder};">
127
+ <table style="border-collapse:collapse;">
128
+ <tr>
129
+ <td style="padding:7px 40px 7px 14px;font-size:9px;color:#1a1a1a;white-space:nowrap;vertical-align:middle;">
130
+ <span style="color:${c.headerBg};font-weight:700;">Client/Patient Full Name</span>
131
+ &nbsp;&nbsp;&nbsp;${meta.patientName}
132
+ </td>
133
+ <td style="padding:7px 40px 7px 0;font-size:9px;color:#1a1a1a;white-space:nowrap;vertical-align:middle;">
134
+ <span style="color:${c.headerBg};font-weight:700;">Age</span>
135
+ &nbsp;&nbsp;&nbsp;${meta.age}
136
+ </td>
137
+ <td style="padding:7px 0 7px 0;font-size:9px;color:#1a1a1a;white-space:nowrap;vertical-align:middle;">
138
+ <span style="color:${c.headerBg};font-weight:700;">BMI</span>
139
+ &nbsp;&nbsp;&nbsp;${meta.bmi}
140
+ </td>
141
+ </tr>
142
+ </table>
143
+ </div>`;
144
+
145
+ // ── "SYSTEM SCORES" subheader band ───────────────────────────────────
146
+ const subheaderBg = c.subheaderBg || "#1B4080";
147
+ const subheaderBand = `
148
+ <div style="
149
+ padding: 5px 14px;
150
+ background: ${subheaderBg};
151
+ color: #ffffff;
152
+ font-size: 8.5px;
153
+ font-weight: 700;
154
+ letter-spacing: 0.8px;
155
+ text-transform: uppercase;
156
+ ">SYSTEM SCORES</div>`;
157
+
158
+ // ── column widths ──────────────────────────────────────────────────────
159
+ const COL = {
160
+ system: "width:34%;text-align:left;",
161
+ high: "width:8%; text-align:center;",
162
+ questions: "width:9%; text-align:center;",
163
+ result: "width:8%; text-align:center;",
164
+ maximum: "width:9%; text-align:center;",
165
+ perThree: "width:8%; text-align:center;",
166
+ comments: "width:24%;text-align:left;",
167
+ };
168
+
169
+ const TH_BASE = `padding:6px 14px;font-size:8px;font-weight:700;color:#ffffff;
170
+ background:${c.headerBg};border-bottom:2px solid rgba(255,255,255,0.2);`;
171
+
172
+ const th = (label, colKey) =>
173
+ `<th style="${TH_BASE}${COL[colKey]}">${label}</th>`;
174
+
175
+ const tableHeader = `
176
+ <tr>
177
+ ${th("SYSTEM", "system")}
178
+ ${th("High", "high")}
179
+ ${th("Questions", "questions")}
180
+ ${th("Result", "result")}
181
+ ${th("Maximum", "maximum")}
182
+ ${th("/3", "perThree")}
183
+ ${th("Comments", "comments")}
184
+ </tr>`;
185
+
186
+ // ── data rows ─────────────────────────────────────────────────────────
187
+ const TD = (content, colKey, extra = "") =>
188
+ `<td style="padding:5px 14px;font-size:8.5px;${COL[colKey]}border-bottom:1px solid ${c.rowBorder};${extra}">${content}</td>`;
189
+
190
+ const dataRows = rows
191
+ .map((row, i) => {
192
+ const hasThreshold = row.high !== null && row.high !== undefined;
193
+ const hasQuantity = row.questions !== null && row.questions !== undefined;
194
+ const hasMaximum = row.maximum !== null && row.maximum !== undefined;
195
+
196
+ const highDisplay = hasThreshold
197
+ ? `<span style="color:${c.elevatedText};font-weight:600;">${row.high}</span>`
198
+ : "\u2014";
199
+
200
+ const questionsDisplay = hasQuantity ? String(row.questions) : "\u2014";
201
+ const resultDisplay = String(row.result);
202
+ const maximumDisplay = hasMaximum ? fmt(row.maximum, 0) : "\u2014";
203
+ const perThreeDisplay = hasMaximum ? fmt(row.maximum / 3, 2) : "\u2014";
204
+
205
+ const rowBg = i % 2 === 0 ? "#ffffff" : "rgba(15, 32, 98, 0.03)";
206
+
207
+ return `
208
+ <tr style="background:${rowBg};">
209
+ ${TD(`<span style="color:#1a1a1a;">${row.system}</span>`, "system")}
210
+ ${TD(highDisplay, "high")}
211
+ ${TD(questionsDisplay, "questions")}
212
+ ${TD(resultDisplay, "result", `font-weight:600;color:#444;`)}
213
+ ${TD(maximumDisplay, "maximum")}
214
+ ${TD(perThreeDisplay, "perThree")}
215
+ ${TD("", "comments")}
216
+ </tr>`;
217
+ })
218
+ .join("");
219
+
220
+ // ── assemble ──────────────────────────────────────────────────────────
221
+ // Outer wrapper uses padding-top instead of an empty spacer div, because
222
+ // empty divs with only `height` get collapsed by wkhtmltopdf. Padding
223
+ // always renders.
224
+ return `
225
+ <div style="padding-top:25px;font-family:'Open Sans',sans-serif;">
226
+ <div class="biq-wrapper" style="
227
+ font-family: 'Open Sans', sans-serif;
228
+ page-break-inside: avoid;
229
+ border-radius: 4px;
230
+ overflow: hidden;
231
+ border: 1px solid ${c.rowBorder};
232
+ ">
233
+ <!-- title bar -->
234
+ <div style="
235
+ background: ${c.headerBg};
236
+ border-radius: 4px 4px 0 0;
237
+ color: #ffffff;
238
+ padding: 9px 14px;
239
+ font-size: 11px;
240
+ font-weight: 700;
241
+ letter-spacing: 0.3px;
242
+ ">${title}</div>
243
+
244
+ ${infoBar}
245
+ ${subheaderBand}
246
+
247
+ <table style="width:100%;border-collapse:collapse;table-layout:fixed;">
248
+ <thead>${tableHeader}</thead>
249
+ <tbody>${dataRows}</tbody>
250
+ </table>
251
+ </div>
252
+ </div>`;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Public entry
257
+ // ---------------------------------------------------------------------------
258
+ /**
259
+ * Render the Big Integral Questionnaire section for Maison Sante doctor PDFs.
260
+ *
261
+ * @param {object} opts
262
+ * @param {object} opts.clientConfig - The full maisonsante.json config object
263
+ * @param {object} opts.localeService - localeService with .t(key) method (unused for now; strings are hardcoded to match the reference design)
264
+ * @returns {string} HTML string to inject into #big-integral-section
265
+ */
266
+ function renderBigIntegralQuestionnaire({ clientConfig }) {
267
+ const cfg = (clientConfig && clientConfig.big_integral_questionnaire) || {};
268
+
269
+ const colors = Object.assign(
270
+ {
271
+ headerBg: "#0F2062",
272
+ subheaderBg: "#1B4080",
273
+ headerText: "#FFFFFF",
274
+ rowBorder: "rgba(15, 32, 98, 0.12)",
275
+ elevatedText: "#A43B2F",
276
+ },
277
+ cfg.colors || {},
278
+ );
279
+
280
+ const resolvedCfg = Object.assign({}, cfg, { colors });
281
+
282
+ const { rows: allRows, meta } = getDummyData();
283
+
284
+ const rows = allRows.filter((row) => {
285
+ if (
286
+ row.system === "Hormonal Health Female" &&
287
+ cfg.showHormonalFemale === false
288
+ )
289
+ return false;
290
+ if (row.system === "Hormonal Health Male" && cfg.showHormonalMale === false)
291
+ return false;
292
+ return true;
293
+ });
294
+
295
+ return renderTable(rows, meta, resolvedCfg);
296
+ }
297
+
298
+ module.exports = { renderBigIntegralQuestionnaire };
package/lib/template.js CHANGED
@@ -3,6 +3,9 @@ const i18n = require('../app/i18n_wellbeing.config.js');
3
3
  fetchFileContents
4
4
  const fs = require("fs");
5
5
  const path = require('path');
6
+
7
+ let useMonospace = false;
8
+ function setUseMonospace(val) { useMonospace = val; }
6
9
  const INDENTATION = {
7
10
  PANEL_TITLE: 12,
8
11
  BIOMARKER_DETAILS: 12,
@@ -1165,7 +1168,12 @@ function renderBiomarkerV3(biomarker, nestingLevel, isChildBiomarker, isCompactV
1165
1168
  }
1166
1169
  let notesPadding = isRtl ? 'padding: 0px 16px 10px 0px;' : 'padding: 0px 0px 10px 16px;';
1167
1170
  let classNote = !isCompactView ? 'note-margin-bottom' : '';
1168
- newBiomarkerHtml += `<div class="biomarker-notes 2 ${classNote}" style="color: #8F8F92; ${notesPadding} ${BioNoteStyle}">${biomarker.notes.join('<br/>')}</div>`;
1171
+ if (useMonospace) {
1172
+ const monoStyle = `font-style: normal; font-size: 13px; color: #333; font-family: 'Roboto Mono', monospace; white-space: pre-wrap; overflow-x: auto;`;
1173
+ newBiomarkerHtml += `<div class="biomarker-notes 2 ${classNote}" style="${notesPadding} ${BioNoteStyle}"><span style="${monoStyle}">${biomarker.notes.join('<br/>')}</span></div>`;
1174
+ } else {
1175
+ newBiomarkerHtml += `<div class="biomarker-notes 2 ${classNote}" style="color: #8F8F92; ${notesPadding} ${BioNoteStyle}">${biomarker.notes.join('<br/>')}</div>`;
1176
+ }
1169
1177
  }
1170
1178
  }
1171
1179
 
@@ -1323,17 +1331,10 @@ function renderDetailsListV3(detailsList, nestingLevel, isCompactView) {
1323
1331
  }
1324
1332
 
1325
1333
  function renderNoteV3(note, nestingLevel, isCompactView, showInCompactView, showInDetailsView) {
1326
- let blockStyle = '';
1327
- let plusPadding = 18;
1328
- if (nestingLevel > 0) {
1329
- let extraPadding = ((nestingLevel + 10 % 10) + 1);
1330
- plusPadding = isCompactView ? 18 : 29;
1331
- let levelNesting = extraPadding + nestingLevel * INDENTATION.BIOMARKER_DETAILS + plusPadding;
1332
- blockStyle = `padding-left: ${levelNesting}px`;
1333
- }
1334
- // if (note) {
1335
- // return `<div style="padding: 2px ${plusPadding}px; ${blockStyle}" class="note-block">${note}</div>`;
1336
- // }
1334
+ // Standalone biomarkerNote items duplicate the note already rendered inside
1335
+ // renderBiomarkerV3 (biomarker.notes), so they are intentionally suppressed
1336
+ // here to avoid every note appearing twice. Monospace styling for PHA is
1337
+ // handled where biomarker.notes is rendered.
1337
1338
  return '';
1338
1339
  }
1339
1340
 
@@ -1585,9 +1586,16 @@ function renderOneAllergyBiomarker(biomarker, isRtl, nestingLevel, isChildBiomar
1585
1586
  }
1586
1587
  bioIndentStyle = isRtl ? 'padding-right: 16px;' : 'padding-left: 16px;';
1587
1588
 
1588
- biomarkerDetailsHtml += '<div class="biomarker-notes 1" style="color: #8F8F92;padding-top: 10px;' + bioIndentStyle + '">';
1589
- biomarkerDetailsHtml += biomarker.notes.join('<br/>');
1590
- biomarkerDetailsHtml += '</div>';
1589
+ if (useMonospace) {
1590
+ const monoStyle = `font-style: normal; font-size: 13px; color: #333; font-family: 'Roboto Mono', monospace; white-space: pre-wrap; overflow-x: auto;`;
1591
+ biomarkerDetailsHtml += '<div class="biomarker-notes 1" style="padding-top: 10px;' + bioIndentStyle + '">';
1592
+ biomarkerDetailsHtml += `<span style="${monoStyle}">` + biomarker.notes.join('<br/>') + '</span>';
1593
+ biomarkerDetailsHtml += '</div>';
1594
+ } else {
1595
+ biomarkerDetailsHtml += '<div class="biomarker-notes 1" style="color: #8F8F92;padding-top: 10px;' + bioIndentStyle + '">';
1596
+ biomarkerDetailsHtml += biomarker.notes.join('<br/>');
1597
+ biomarkerDetailsHtml += '</div>';
1598
+ }
1591
1599
  }
1592
1600
  biomarkerDetailsHtml += '</div> <!-- end first col -->';
1593
1601
 
@@ -2196,8 +2204,9 @@ module.exports = {
2196
2204
  find,
2197
2205
  renderSummary,
2198
2206
  renderReportSummary,
2199
- renderHTMLTemplate
2200
-
2207
+ renderHTMLTemplate,
2208
+ renderAllergyClasses,
2209
+ setUseMonospace,
2201
2210
  }
2202
2211
 
2203
2212
 
@@ -7,7 +7,7 @@ const path = require("path");
7
7
  const LocaleService = require('../app/services/localeService.js');
8
8
  const i18n = require('../app/i18n_wellbeing.config.js');
9
9
  const puppeteer = require("puppeteer");
10
- const { renderReportItems, find, renderSummary, renderReportSummary, renderHTMLTemplate, renderAllergyClasses } = require("./template.js");
10
+ const { renderReportItems, find, renderSummary, renderReportSummary, renderHTMLTemplate, renderAllergyClasses, setUseMonospace } = require("./template.js");
11
11
  const { cli } = require("webpack");
12
12
 
13
13
 
@@ -1337,12 +1337,29 @@ let generateHTMLWellbeingReportWithSmartReport = async (data, isDebugging, clien
1337
1337
  $('.doctor-details').html(doctorDetailsHtml);
1338
1338
  }
1339
1339
 
1340
+ // Render Big Integral Questionnaire section (Maison Sante doctor PDF only)
1341
+ if (client.toLowerCase() === 'maisonsante' && data.IsDoctor) {
1342
+ const { renderBigIntegralQuestionnaire } = require('./big_integral_questionnaire');
1343
+ const rawClientConfig = (() => {
1344
+ try {
1345
+ const cfgPath = path.resolve(__dirname + '/../config/maisonsante.json');
1346
+ return JSON.parse(require('fs').readFileSync(cfgPath, 'utf8'));
1347
+ } catch (e) { return {}; }
1348
+ })();
1349
+ const integralHtml = renderBigIntegralQuestionnaire({
1350
+ clientConfig: rawClientConfig,
1351
+ });
1352
+ $('#big-integral-section').html(integralHtml);
1353
+ }
1354
+
1340
1355
  // Update report info
1341
1356
  $('.report-info').html(headerInfoHtml);
1342
1357
 
1343
1358
  // Add smart report if available
1344
1359
  if (!empty(smartReport)) {
1360
+ setUseMonospace(client.toLowerCase() === 'pha');
1345
1361
  let smartReportHtml = generateReportHtml(smartReport, isRtl, false, false, language).html;
1362
+ setUseMonospace(false);
1346
1363
 
1347
1364
  if (shouldHideWellbeingUI) {
1348
1365
  // Parse and clean smart report HTML to remove page breaks when wellbeing is missing
package/locales/en.json CHANGED
@@ -162,5 +162,69 @@
162
162
  "signedBy": {
163
163
  "message": "Signed by",
164
164
  "description": "Label showing above the doctors who signed on this report"
165
+ },
166
+ "bigIntegralTitle": {
167
+ "message": "Key to the Big Integral Questionnaire",
168
+ "description": "Title of the Big Integral Questionnaire section in the Maison Sante doctor PDF"
169
+ },
170
+ "bigIntegralSystem": {
171
+ "message": "System",
172
+ "description": "Column header: body system name"
173
+ },
174
+ "bigIntegralHigh": {
175
+ "message": "High",
176
+ "description": "Column header: threshold above which result is considered elevated"
177
+ },
178
+ "bigIntegralQuestions": {
179
+ "message": "Questions",
180
+ "description": "Column header: number of questions for this system"
181
+ },
182
+ "bigIntegralResult": {
183
+ "message": "Result",
184
+ "description": "Column header: patient's result score"
185
+ },
186
+ "bigIntegralComments": {
187
+ "message": "Comments",
188
+ "description": "Column header: free-text comments area"
189
+ },
190
+ "bigIntegralMaximum": {
191
+ "message": "Maximum",
192
+ "description": "Column header: maximum possible score for this system"
193
+ },
194
+ "bigIntegralPerThree": {
195
+ "message": "/3",
196
+ "description": "Column header: score divided by 3"
197
+ },
198
+ "bigIntegralDateOfCompletion": {
199
+ "message": "Date of completion",
200
+ "description": "Footer meta label"
201
+ },
202
+ "bigIntegralPatientName": {
203
+ "message": "Client / Patient Full Name",
204
+ "description": "Footer meta label"
205
+ },
206
+ "bigIntegralAge": {
207
+ "message": "Age",
208
+ "description": "Footer meta label"
209
+ },
210
+ "bigIntegralBmi": {
211
+ "message": "BMI",
212
+ "description": "Footer meta label"
213
+ },
214
+ "bigIntegralElevated": {
215
+ "message": "ELEVATED",
216
+ "description": "Badge text shown when a system result exceeds its threshold"
217
+ },
218
+ "bigIntegralVariantClinical": {
219
+ "message": "Clinical View",
220
+ "description": "Variant label shown in the section header for the clinical table design"
221
+ },
222
+ "bigIntegralVariantScorecard": {
223
+ "message": "Scorecard View",
224
+ "description": "Variant label shown in the section header for the scorecard bar design"
225
+ },
226
+ "bigIntegralVariantHybrid": {
227
+ "message": "Hybrid View",
228
+ "description": "Variant label shown in the section header for the hybrid (table + bar) design"
165
229
  }
166
230
  }