internationalscholarsprogram commited on
Commit
d58b2e3
·
verified ·
1 Parent(s): dc60ef7

Fix: Tier 2 numbered list, sub-bullet checkmarks, Relocation Cost page, ISP FINANCING, green NB

Browse files
app/services/normalizer.py CHANGED
@@ -129,6 +129,12 @@ def normalize_section(
129
  # ── doc_v1 ──
130
  if layout_norm == "doc_v1" and isinstance(section_json.get("blocks"), list):
131
  blocks.extend(_normalize_doc_v1(section_json["blocks"], skip_title=title))
 
 
 
 
 
 
132
  return blocks
133
 
134
  # ── Fallback ──
@@ -513,6 +519,230 @@ def _normalize_table_v2(json_data: dict) -> RenderBlock:
513
  )
514
 
515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  def _normalize_doc_v1(blocks: list, *, skip_title: str = "") -> list[RenderBlock]:
517
  """Normalise doc_v1 blocks into typed RenderBlocks.
518
 
 
129
  # ── doc_v1 ──
130
  if layout_norm == "doc_v1" and isinstance(section_json.get("blocks"), list):
131
  blocks.extend(_normalize_doc_v1(section_json["blocks"], skip_title=title))
132
+ # Post-process breakdown section for Relocation Cost layout
133
+ if key_norm == "program_features_breakdown":
134
+ blocks = _postprocess_breakdown(blocks, section_json["blocks"])
135
+ # Post-process Tier 2 section for sub-bullet styling
136
+ if key_norm == "summary_of_universities_cosigner":
137
+ blocks = _postprocess_tier2(blocks)
138
  return blocks
139
 
140
  # ── Fallback ──
 
519
  )
520
 
521
 
522
+ # ───────────────────────────────────────────────────────────────
523
+ # Breakdown section post-processor
524
+ # ───────────────────────────────────────────────────────────────
525
+
526
+ def _postprocess_breakdown(
527
+ blocks: list[RenderBlock],
528
+ raw_blocks: list,
529
+ ) -> list[RenderBlock]:
530
+ """Rewrite the breakdown section to match the reference layout.
531
+
532
+ - "Relocation Cost" becomes a banner heading with page-break-before
533
+ - The relocation table gets a merged right cell (rowspan) with the
534
+ cost-coverage note moved inside it
535
+ - "ISP FINANCING" becomes an inline note with mixed bold/italic
536
+ - "NB: CREDIT FACILITY" is styled green
537
+ - Dollar amounts in parentheticals keep their original $ format
538
+ """
539
+ from markupsafe import Markup
540
+
541
+ # Find raw blocks for the relocation cost table (pre-normalised, $ intact)
542
+ raw_reloc_table = None
543
+ raw_note_after_table = None
544
+ found_reloc = False
545
+ for i, rb in enumerate(raw_blocks):
546
+ if not isinstance(rb, dict):
547
+ continue
548
+ if rb.get("type") == "subheading" and "relocation" in str(rb.get("text", "")).lower():
549
+ found_reloc = True
550
+ continue
551
+ if found_reloc and rb.get("type") == "table_v1" and raw_reloc_table is None:
552
+ raw_reloc_table = rb
553
+ continue
554
+ if found_reloc and raw_reloc_table and rb.get("type") == "paragraph" and raw_note_after_table is None:
555
+ raw_note_after_table = rb
556
+ break
557
+
558
+ result: list[RenderBlock] = []
559
+ i = 0
560
+ while i < len(blocks):
561
+ blk = blocks[i]
562
+
563
+ # ── Detect "Relocation Cost" heading ──
564
+ if (blk.block_type == "heading_2"
565
+ and "relocation" in blk.data.get("text", "").lower()):
566
+
567
+ # Banner heading with page break
568
+ result.append(RenderBlock(
569
+ block_type="heading_2",
570
+ css_class="hb-heading-2 hb-banner-heading page-break",
571
+ data={"text": blk.data["text"]},
572
+ ))
573
+ i += 1
574
+
575
+ # Replace the next table with spanning variant that has merged cell
576
+ if i < len(blocks) and blocks[i].block_type == "table" and raw_reloc_table:
577
+ raw_rows = raw_reloc_table.get("rows", [])
578
+ # Build the note text for the merged right cell
579
+ note_text = ""
580
+ if raw_note_after_table:
581
+ note_text = str(raw_note_after_table.get("text", ""))
582
+
583
+ spanning_rows = _build_relocation_spanning_rows(raw_rows, note_text)
584
+ result.append(RenderBlock(
585
+ block_type="table",
586
+ css_class="hb-table hb-relocation-table",
587
+ data={"rows": spanning_rows, "variant": "spanning"},
588
+ ))
589
+ i += 1 # skip the original table
590
+
591
+ # Skip the paragraph that was moved into the merged cell
592
+ if (i < len(blocks)
593
+ and blocks[i].block_type == "paragraph"
594
+ and note_text):
595
+ i += 1
596
+ continue
597
+
598
+ # ── "ISP FINANCING" heading → inline note with mixed formatting ──
599
+ if (blk.block_type == "heading_2"
600
+ and "isp financing" in blk.data.get("text", "").lower()):
601
+ # Next block should be the interest rate paragraph
602
+ rate_text = ""
603
+ if i + 1 < len(blocks) and blocks[i + 1].block_type == "paragraph":
604
+ rate_text = blocks[i + 1].data.get("text", "")
605
+ result.append(RenderBlock(
606
+ block_type="note",
607
+ css_class="hb-note hb-isp-financing",
608
+ data={
609
+ "parts": [
610
+ {"text": "ISP FINANCING", "style": "bold"},
611
+ {"text": " (" + _extract_rate_italic(rate_text) + "): " if rate_text else "", "style": "italic"},
612
+ {"text": _extract_rate_amount(rate_text), "style": "bold"},
613
+ ],
614
+ "inline": True,
615
+ },
616
+ ))
617
+ i += 1 # skip the heading
618
+ if rate_text:
619
+ i += 1 # skip the paragraph
620
+ continue
621
+
622
+ # ── "NB: CREDIT FACILITY" note → green styling ──
623
+ if (blk.block_type == "note"
624
+ and "credit facility" in blk.data.get("text", "").lower()):
625
+ result.append(RenderBlock(
626
+ block_type="note",
627
+ css_class="hb-note hb-credit-note",
628
+ data=blk.data,
629
+ ))
630
+ i += 1
631
+ continue
632
+
633
+ result.append(blk)
634
+ i += 1
635
+
636
+ return result
637
+
638
+
639
+ def _build_relocation_spanning_rows(
640
+ raw_rows: list, note_text: str,
641
+ ) -> list[list[dict]]:
642
+ """Build spanning rows for the relocation cost table.
643
+
644
+ Row 0: normal 2-column (consultation fees | Covered in the contribution)
645
+ Rows 1-7: left cell per row, right cell merged (rowspan) with italic note
646
+ Rows 8+: left cell only, empty right
647
+ """
648
+ from markupsafe import Markup
649
+
650
+ if not raw_rows:
651
+ return []
652
+
653
+ rows: list[list[dict]] = []
654
+
655
+ # Row 0 — has "Covered in the contribution"
656
+ first = raw_rows[0] if raw_rows else ["", ""]
657
+ rows.append([
658
+ {"text": Markup(emphasize_keywords(str(first[0] if len(first) > 0 else ""))), "colspan": 1, "rowspan": 1},
659
+ {"text": Markup("<em>" + h(str(first[1] if len(first) > 1 else "")) + "</em>"), "colspan": 1, "rowspan": 1},
660
+ ])
661
+
662
+ # Rows 1-7: items with dollar amounts that get the merged right cell
663
+ # These are the visa/fee/rent/ticket rows (have parenthetical dollar amounts)
664
+ merged_start = 1
665
+ merged_end = min(8, len(raw_rows)) # Visa Integrity through Air ticket
666
+
667
+ for idx in range(merged_start, len(raw_rows)):
668
+ cell_text = str(raw_rows[idx][0] if len(raw_rows[idx]) > 0 else "")
669
+ left = {"text": Markup(emphasize_keywords(cell_text)), "colspan": 1, "rowspan": 1}
670
+
671
+ if idx == merged_start and note_text:
672
+ # First merged row gets the rowspan cell
673
+ span_count = merged_end - merged_start
674
+ note_html = note_text.replace("\n\n", "<br/><br/>")
675
+ right = {
676
+ "text": Markup('<em class="hb-merged-note">' + h(note_html).replace("&lt;br/&gt;&lt;br/&gt;", "<br/><br/>") + "</em>"),
677
+ "colspan": 1,
678
+ "rowspan": span_count,
679
+ }
680
+ rows.append([left, right])
681
+ elif idx < merged_end:
682
+ # Subsequent merged rows — no right cell (covered by rowspan)
683
+ rows.append([left])
684
+ else:
685
+ # Remaining rows — empty right cell
686
+ rows.append([
687
+ left,
688
+ {"text": "", "colspan": 1, "rowspan": 1},
689
+ ])
690
+
691
+ return rows
692
+
693
+
694
+ def _extract_rate_italic(text: str) -> str:
695
+ """Extract the italic portion: 'Interest rate of 12% – 15% Market Rate PA'."""
696
+ # Text is like: "Interest rate of 12% – 15% Market Rate: UP TO USD 10,000"
697
+ m = re.match(r"(Interest rate.*?(?:Market Rate|PA))", text, re.IGNORECASE)
698
+ if m:
699
+ return m.group(1).rstrip(": ")
700
+ # Fallback: everything before the colon
701
+ if ":" in text:
702
+ return text.split(":")[0].strip()
703
+ return text
704
+
705
+
706
+ def _extract_rate_amount(text: str) -> str:
707
+ """Extract the amount portion: 'UP TO USD 10,000'."""
708
+ m = re.search(r"(UP TO.*)", text, re.IGNORECASE)
709
+ if m:
710
+ return m.group(1).strip()
711
+ if ":" in text:
712
+ return text.split(":", 1)[1].strip()
713
+ return ""
714
+
715
+
716
+ # ───────────────────────────────────────────────────────────────
717
+ # Tier 2 (cosigner) section post-processor
718
+ # ───────────────────────────────────────────────────────────────
719
+
720
+ def _postprocess_tier2(blocks: list[RenderBlock]) -> list[RenderBlock]:
721
+ """Style the Tier 2 section to match the reference layout.
722
+
723
+ - Second consecutive bullet_list (sub-bullets under Sources of Funds)
724
+ gets checkmark styling instead of arrows.
725
+ """
726
+ result: list[RenderBlock] = []
727
+ prev_was_bullet = False
728
+ for blk in blocks:
729
+ if blk.block_type == "bullet_list":
730
+ if prev_was_bullet:
731
+ # This is the sub-bullet list → use checkmark class
732
+ result.append(RenderBlock(
733
+ block_type="bullet_list",
734
+ css_class="hb-bullet-list hb-sub-bullets",
735
+ data=blk.data,
736
+ ))
737
+ else:
738
+ result.append(blk)
739
+ prev_was_bullet = True
740
+ else:
741
+ prev_was_bullet = False
742
+ result.append(blk)
743
+ return result
744
+
745
+
746
  def _normalize_doc_v1(blocks: list, *, skip_title: str = "") -> list[RenderBlock]:
747
  """Normalise doc_v1 blocks into typed RenderBlocks.
748
 
app/static/css/print.css CHANGED
@@ -215,7 +215,7 @@ ul.hb-bullet-list li::before {
215
  .hb-numbered-list,
216
  .ol,
217
  ol.hb-numbered-list {
218
- list-style: decimal;
219
  margin: 0 0 8pt 18pt;
220
  padding: 0;
221
  font-size: 9pt;
@@ -234,7 +234,18 @@ ol.hb-numbered-list li {
234
  .hb-numbered-list li::before,
235
  .ol li::before,
236
  ol.hb-numbered-list li::before {
237
- content: none;
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
  /* ------------------------------
@@ -1088,4 +1099,75 @@ table.programs td:nth-child(5) {
1088
  .page-content img {
1089
  max-width: 100%;
1090
  height: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
  }
 
215
  .hb-numbered-list,
216
  .ol,
217
  ol.hb-numbered-list {
218
+ list-style: decimal !important;
219
  margin: 0 0 8pt 18pt;
220
  padding: 0;
221
  font-size: 9pt;
 
234
  .hb-numbered-list li::before,
235
  .ol li::before,
236
  ol.hb-numbered-list li::before {
237
+ content: none !important;
238
+ }
239
+
240
+ /* Sub-bullets — checkmarks, indented under parent bullet */
241
+ .hb-sub-bullets {
242
+ margin-left: 32pt !important;
243
+ }
244
+
245
+ .hb-sub-bullets li::before {
246
+ content: "\2713" !important;
247
+ color: #000000;
248
+ font-size: 9pt;
249
  }
250
 
251
  /* ------------------------------
 
1099
  .page-content img {
1100
  max-width: 100%;
1101
  height: auto;
1102
+ }
1103
+
1104
+ /* ------------------------------
1105
+ BREAKDOWN — RELOCATION COST
1106
+ ------------------------------ */
1107
+
1108
+ /* Banner heading (teal background bar) */
1109
+ .hb-banner-heading {
1110
+ background-color: #199970;
1111
+ color: #FFFFFF !important;
1112
+ padding: 6pt 10pt;
1113
+ margin: 10pt 0 6pt;
1114
+ font-size: 11pt;
1115
+ font-weight: 700;
1116
+ }
1117
+
1118
+ /* Page break before Relocation Cost */
1119
+ .sec-breakdown .page-break {
1120
+ page-break-before: always;
1121
+ break-before: page;
1122
+ }
1123
+
1124
+ /* Relocation cost table — merged note cell */
1125
+ .hb-relocation-table {
1126
+ margin: 0 0 10pt;
1127
+ }
1128
+
1129
+ .hb-relocation-table td {
1130
+ vertical-align: top;
1131
+ padding: 3pt 6pt;
1132
+ border: 1pt solid #CCCCCC;
1133
+ font-size: 9.5pt;
1134
+ }
1135
+
1136
+ .hb-merged-note {
1137
+ font-style: italic;
1138
+ font-weight: 400;
1139
+ font-size: 9.5pt;
1140
+ line-height: 1.4;
1141
+ display: block;
1142
+ padding: 4pt 2pt;
1143
+ }
1144
+
1145
+ /* ISP FINANCING line */
1146
+ .hb-isp-financing {
1147
+ margin: 14pt 0 6pt;
1148
+ padding: 6pt 0;
1149
+ border-top: 1.5pt solid #000000;
1150
+ border-bottom: 1.5pt solid #000000;
1151
+ text-align: center;
1152
+ font-size: 10pt;
1153
+ }
1154
+
1155
+ .hb-isp-financing strong {
1156
+ font-weight: 700;
1157
+ color: #000000;
1158
+ }
1159
+
1160
+ .hb-isp-financing em {
1161
+ font-style: italic;
1162
+ color: #199970;
1163
+ font-weight: 700;
1164
+ }
1165
+
1166
+ /* NB: CREDIT FACILITY — green */
1167
+ .hb-credit-note {
1168
+ text-align: center;
1169
+ color: #199970;
1170
+ font-size: 10pt;
1171
+ font-weight: 700;
1172
+ margin: 6pt 0;
1173
  }
app/templates/partials/blocks/heading.html CHANGED
@@ -1,6 +1,6 @@
1
  {# Block partial: heading_1 / heading_2 #}
2
  {% if block.block_type == 'heading_1' %}
3
- <h2 class="hb-heading-1">{{ block.data.text | e }}</h2>
4
  {% elif block.block_type == 'heading_2' %}
5
- <h3 class="hb-heading-2">{{ block.data.text | e }}</h3>
6
  {% endif %}
 
1
  {# Block partial: heading_1 / heading_2 #}
2
  {% if block.block_type == 'heading_1' %}
3
+ <h2 class="{{ block.css_class | default('hb-heading-1') }}">{{ block.data.text | e }}</h2>
4
  {% elif block.block_type == 'heading_2' %}
5
+ <h3 class="{{ block.css_class | default('hb-heading-2') }}">{{ block.data.text | e }}</h3>
6
  {% endif %}
app/templates/partials/blocks/note.html CHANGED
@@ -1,16 +1,20 @@
1
  {# Block partial: note (standalone or inline-parts) #}
2
  {% if block.data.inline and block.data.parts %}
3
- <div class="hb-note">
4
  {% for part in block.data.parts %}
5
  {% if part.style == 'red_bold' %}
6
  <strong class="hb-note-keyword">{{ part.text | e }}</strong>
 
 
 
 
7
  {% else %}
8
  {{ part.text | e }}
9
  {% endif %}
10
  {% endfor %}
11
  </div>
12
  {% else %}
13
- <div class="hb-note">
14
  {% set text = block.data.text | default('') %}
15
  {# Highlight NOTE / ONLY IF keywords in bold + red; rest stays bold via CSS #}
16
  {% if text.upper().startswith('NOTE:') %}
 
1
  {# Block partial: note (standalone or inline-parts) #}
2
  {% if block.data.inline and block.data.parts %}
3
+ <div class="{{ block.css_class | default('hb-note') }}">
4
  {% for part in block.data.parts %}
5
  {% if part.style == 'red_bold' %}
6
  <strong class="hb-note-keyword">{{ part.text | e }}</strong>
7
+ {% elif part.style == 'bold' %}
8
+ <strong>{{ part.text | e }}</strong>
9
+ {% elif part.style == 'italic' %}
10
+ <em>{{ part.text | e }}</em>
11
  {% else %}
12
  {{ part.text | e }}
13
  {% endif %}
14
  {% endfor %}
15
  </div>
16
  {% else %}
17
+ <div class="{{ block.css_class | default('hb-note') }}">
18
  {% set text = block.data.text | default('') %}
19
  {# Highlight NOTE / ONLY IF keywords in bold + red; rest stays bold via CSS #}
20
  {% if text.upper().startswith('NOTE:') %}