Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions backend/api/latex_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"large": ("1.2pt", "1.2pt"),
}


FONT_SIZE_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$")
SPACING_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)pt$")
BODY_FONT_COMMAND_PATTERN = re.compile(
Expand All @@ -47,7 +46,7 @@
LEGACY_ANSWER_LABEL_PATTERN = re.compile(r"\\textbf\{Answer:\}\s*")
APP_LAYOUT_COMMENT_LINE_PATTERN = re.compile(r"(?m)^% @cheatsheet-layout .*\n?")
APP_LAYOUT_COMMENT_BLOCK_PATTERN = re.compile(
r"(?m)(?:^% @cheatsheet-layout .*\n){4}^%\n?"
r"(?m)(?:^% @cheatsheet-layout .*\n)+^%\n?"
)


Expand Down Expand Up @@ -130,31 +129,42 @@ def append_text_heading(lines, text):
lines.append(r"\noindent " + text + r"\par")


def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
return [
f"% @cheatsheet-layout columns: {columns} | change layout options up top to update columns",
f"% @cheatsheet-layout font_size: {font_size} | change layout options up top to update text size",
f"% @cheatsheet-layout spacing: {spacing} | change layout options up top to update spacing",
f"% @cheatsheet-layout margins: {margins} | change layout options up top to update margins",
f"% @cheatsheet-layout orientation: {orientation} | change layout options up top to update orientation",
"%",
]


def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""
Build a dynamic LaTeX header based on user-selected options.
"""
size_command = get_body_font_command(font_size)
spacing_values = get_spacing_values(spacing, font_size)
doc_class, doc_class_size = get_document_class(font_size)

# 1. Force the PDF driver to rotate by passing landscape and letterpaper to the document class
doc_options = f"{doc_class_size},fleqn,letterpaper"
if orientation == "landscape":
doc_options += ",landscape"

# 2. Also pass them to the geometry package
geometry_options = f"letterpaper,margin={margins}"
if orientation == "landscape":
geometry_options += ",landscape"

header_lines = [
f"\\documentclass[{doc_class_size},fleqn]{{{doc_class}}}",
f"\\usepackage[margin={margins}]{{geometry}}",
f"\\documentclass[{doc_options}]{{{doc_class}}}",
f"\\usepackage[{geometry_options}]{{geometry}}",
"\\usepackage{amsmath, amssymb}",
"\\usepackage{enumitem}",
"\\usepackage{multicol}",
"\\usepackage{adjustbox}", # For auto-scaling equations to fit column width
"\\usepackage{adjustbox}",
"",
"\\setlength{\\mathindent}{0pt}",
"\\setlist[itemize]{noitemsep, topsep=0pt, leftmargin=*}",
Expand Down Expand Up @@ -188,12 +198,12 @@ def build_dynamic_footer(columns=2):
return "\n".join(footer_lines)


def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""Rebuild document wrappers so current layout controls apply to existing LaTeX content."""
if not content:
return content

header = build_dynamic_header(columns, font_size, margins, spacing)
header = build_dynamic_header(columns, font_size, margins, spacing, orientation)
footer = build_dynamic_footer(columns)

if r"\begin{document}" not in content or r"\end{document}" not in content:
Expand All @@ -219,26 +229,26 @@ def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in"
body = re.sub(r"(?m)^\\vspace\{[^}]+\}\s*$", rf"\\vspace{{{formula_gap}}}", body)
body = body.strip("\n")

layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing))
layout_comment_block = "\n".join(build_layout_comment_block(columns, font_size, margins, spacing, orientation))
body = layout_comment_block + ("\n" + body if body else "")

return header + body + ("\n" if body else "") + footer


def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small"):
def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small", orientation="portrait"):
"""
Given a list of selected formulas (each with class_name, category, name, latex),
build a complete LaTeX document.
"""
header = build_dynamic_header(columns, font_size, margins, spacing)
header = build_dynamic_header(columns, font_size, margins, spacing, orientation)
footer = build_dynamic_footer(columns)
formula_gap = get_spacing_values(spacing, font_size)["formula_gap"]

if not selected_formulas:
return header + footer

body_lines = []
body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing))
body_lines.extend(build_layout_comment_block(columns, font_size, margins, spacing, orientation))
current_class = None
current_category = None
in_flushleft = False
Expand Down Expand Up @@ -334,4 +344,4 @@ def compile_latex_to_pdf(content):

# Read and return the PDF bytes before the temporary directory is removed
with open(pdf_file_path, "rb") as pdf_file:
return pdf_file.read()
return pdf_file.read()
18 changes: 18 additions & 0 deletions backend/api/migrations/0008_cheatsheet_orientation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-05-06 02:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0007_cheatsheet_content_source'),
]

operations = [
migrations.AddField(
model_name='cheatsheet',
name='orientation',
field=models.CharField(default='portrait', max_length=20),
),
]
1 change: 1 addition & 0 deletions backend/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class CheatSheet(models.Model):
margins = models.CharField(max_length=20, default="0.15in")
font_size = models.CharField(max_length=10, default="9pt")
spacing = models.CharField(max_length=10, default="small")
orientation = models.CharField(max_length=20, default="portrait")
# Stores selected formulas with user-defined order: [{"class": "...", "category": "...", "name": "..."}]
selected_formulas = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
Expand Down
17 changes: 11 additions & 6 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def test_build_full_latex_wraps_content(self):
columns=1,
font_size="10pt",
user=self.user,
orientation="portrait",
)
full = sheet.build_full_latex()
assert "\\begin{document}" in full
Expand Down Expand Up @@ -237,9 +238,9 @@ def test_normalize_latex_layout_rewraps_existing_document_with_current_settings(
"\\end{document}"
)

normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny")
normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny", orientation="portrait")

assert "\\documentclass[8pt,fleqn]{extarticle}" in normalized
assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in normalized
assert "margin=0.5in" in normalized
assert "\\begin{multicols}{4}" in normalized
assert "\\begin{multicols}{2}" not in normalized
Expand Down Expand Up @@ -383,7 +384,7 @@ def test_build_dynamic_header_keeps_headers_close_to_body_size(self):

def test_build_dynamic_header_accepts_custom_font_and_spacing(self):
header = build_dynamic_header(columns=5, font_size="10.5pt", margins="0.25in", spacing="0.6pt")
assert "\\documentclass[10pt,fleqn]{article}" in header
assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in header
assert "\\fontsize{10.5pt}{11.3pt}\\selectfont" in header
assert "\\setlength{\\baselineskip}{11.1pt}" in header
assert "\\setlength{\\parskip}{0.6pt}" in header
Expand All @@ -402,6 +403,7 @@ def test_build_latex_for_formulas_includes_editable_layout_comments(self):
assert "% @cheatsheet-layout font_size: 10.5pt | change layout options up top to update text size" in tex
assert "% @cheatsheet-layout spacing: 0.6pt | change layout options up top to update spacing" in tex
assert "% @cheatsheet-layout margins: 0.5in | change layout options up top to update margins" in tex
assert "% @cheatsheet-layout orientation: portrait | change layout options up top to update orientation" in tex


# ── API Tests ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1246,7 +1248,7 @@ def test_generate_sheet_8pt_uses_extarticle(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[8pt,fleqn]{extarticle}" in tex
assert "\\documentclass[8pt,fleqn,letterpaper]{extarticle}" in tex
assert "\\documentclass[8pt,fleqn]{article}" not in tex

def test_generate_sheet_9pt_uses_extarticle(self, auth_client):
Expand All @@ -1261,7 +1263,7 @@ def test_generate_sheet_9pt_uses_extarticle(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[9pt,fleqn]{extarticle}" in tex
assert "\\documentclass[9pt,fleqn,letterpaper]{extarticle}" in tex
assert "\\documentclass[9pt,fleqn]{article}" not in tex

def test_generate_sheet_10pt_uses_article(self, auth_client):
Expand All @@ -1276,7 +1278,7 @@ def test_generate_sheet_10pt_uses_article(self, auth_client):
)
assert resp.status_code == 200
tex = resp.json()["tex_code"]
assert "\\documentclass[10pt,fleqn]{article}" in tex
assert "\\documentclass[10pt,fleqn,letterpaper]{article}" in tex
assert "extarticle" not in tex

def test_generate_sheet_latex_injection_blocked(self, auth_client):
Expand Down Expand Up @@ -1375,6 +1377,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
"font_size": "8pt",
"spacing": "tiny",
"margins": "0.25in",
"orientation": "portrait",
},
format="json",
)
Expand All @@ -1387,6 +1390,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
"font_size": "8pt",
"spacing": "tiny",
"margins": "0.25in",
"orientation": "portrait",
}
assert "\\begin{multicols}{2}" in tex
assert "\\fontsize{8pt}{8.8pt}\\selectfont" in tex
Expand All @@ -1395,6 +1399,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se
assert "% @cheatsheet-layout font_size: 8pt | change layout options up top to update text size" in tex
assert "% @cheatsheet-layout spacing: tiny | change layout options up top to update spacing" in tex
assert "% @cheatsheet-layout margins: 0.25in | change layout options up top to update margins" in tex
assert "orientation: portrait" in tex


# ── Auth Endpoint Tests ──────────────────────────────────────────────
Expand Down
49 changes: 17 additions & 32 deletions backend/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@
VALID_FONT_SIZES = {"8pt", "9pt", "10pt", "11pt", "12pt"}
VALID_SPACING = {"tiny", "small", "medium", "large"}
VALID_MARGINS = {"0.15in", "0.25in", "0.5in", "0.75in", "1in", "1.5in", "2in"}
VALID_ORIENTATION = {"portrait", "landscape"}
DEFAULT_COLUMNS = 4
DEFAULT_FONT_SIZE = "9pt"
DEFAULT_SPACING = "small"
DEFAULT_MARGINS = "0.15in"


def is_valid_custom_pt(value, min_value, max_value):
normalized = str(value or "").strip()
if not normalized.endswith("pt"):
Expand All @@ -61,7 +61,7 @@ def is_truthy(value):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)

def validate_layout_params(columns, font_size, margins, spacing):
def validate_layout_params(columns, font_size, margins, spacing, orientation="portrait"):
try:
columns = max(1, min(5, int(columns)))
except (TypeError, ValueError):
Expand All @@ -75,8 +75,11 @@ def validate_layout_params(columns, font_size, margins, spacing):

if spacing not in VALID_SPACING and not is_valid_custom_pt(spacing, 0, 6):
spacing = DEFAULT_SPACING

if orientation not in VALID_ORIENTATION:
orientation = "portrait"

return columns, font_size, margins, spacing
return columns, font_size, margins, spacing, orientation


def build_youtube_search_query(class_name, category_name):
Expand Down Expand Up @@ -280,8 +283,7 @@ def get_classes(request):
@api_view(["POST"])
def generate_sheet(request):
"""
POST /api/generate-sheet/
Accepts { "formulas": [...], "columns": 4, "font_size": "9pt", "margins": "0.15in", "spacing": "small" }
Accepts { "formulas": [...], "columns": 4, "font_size": "9pt", "margins": "0.15in", "spacing": "small", "orientation": "portrait" }
Each formula: { "class": "ALGEBRA I", "category": "Linear Equations", "name": "Slope Formula" }
Or for special classes (like UNIT CIRCLE): { "class": "UNIT CIRCLE", "name": "Unit Circle (Key Angles)" }
Returns { "tex_code": "..." }
Expand All @@ -291,11 +293,12 @@ def generate_sheet(request):
font_size = request.data.get("font_size", DEFAULT_FONT_SIZE)
margins = request.data.get("margins", DEFAULT_MARGINS)
spacing = request.data.get("spacing", DEFAULT_SPACING)
orientation = request.data.get("orientation", "portrait")

columns, font_size, margins, spacing = validate_layout_params(columns, font_size, margins, spacing)
columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation)

if not selected:
tex_code = build_latex_for_formulas([], columns, font_size, margins, spacing)
tex_code = build_latex_for_formulas([], columns, font_size, margins, spacing, orientation)
return Response({"tex_code": tex_code})

formula_data = get_formula_data()
Expand Down Expand Up @@ -342,7 +345,7 @@ def generate_sheet(request):
if not selected_formulas:
return Response({"error": "No valid formulas found"}, status=400)

tex_code = build_latex_for_formulas(selected_formulas, columns, font_size, margins, spacing)
tex_code = build_latex_for_formulas(selected_formulas, columns, font_size, margins, spacing, orientation)
return Response({"tex_code": tex_code})


Expand All @@ -351,10 +354,6 @@ def generate_sheet(request):
def compile_latex(request):
"""
POST /api/compile/
Accepts either:
- { "content": "...full LaTeX code..." }
- { "cheat_sheet_id": 123 }
Compiles with Tectonic on the backend and returns the PDF.
"""
content = request.data.get("content", "")
cheat_sheet_id = request.data.get("cheat_sheet_id")
Expand All @@ -363,9 +362,10 @@ def compile_latex(request):
font_size = request.data.get("font_size", DEFAULT_FONT_SIZE)
margins = request.data.get("margins", DEFAULT_MARGINS)
spacing = request.data.get("spacing", DEFAULT_SPACING)
columns, font_size, margins, spacing = validate_layout_params(columns, font_size, margins, spacing)
orientation = request.data.get("orientation", "portrait")

columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation)

# If cheat_sheet_id is provided, get content from the cheat sheet
if cheat_sheet_id:
cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user)
columns = cheatsheet.columns
Expand All @@ -377,7 +377,7 @@ def compile_latex(request):
if not content:
return Response({"error": "No LaTeX content provided"}, status=400)

content = normalize_latex_layout(content, columns, font_size, margins, spacing)
content = normalize_latex_layout(content, columns, font_size, margins, spacing, orientation)

if normalize_only:
return Response({
Expand All @@ -387,6 +387,7 @@ def compile_latex(request):
"font_size": font_size,
"margins": margins,
"spacing": spacing,
"orientation": orientation,
},
})

Expand Down Expand Up @@ -501,10 +502,6 @@ def youtube_resources(request):
# ------------------------------------------------------------------

class TemplateViewSet(viewsets.ModelViewSet):
"""
CRUD API for Templates
Get/Post/Put/Delete /api/templates/
"""
queryset = Template.objects.all()
serializer_class = TemplateSerializer

Expand All @@ -517,10 +514,6 @@ def get_queryset(self):


class CheatSheetViewSet(viewsets.ModelViewSet):
"""
CRUD API for CheatSheets
Get/Post/Put/Delete /api/cheatsheets/
"""
queryset = CheatSheet.objects.all()
serializer_class = CheatSheetSerializer
permission_classes = [IsAuthenticated]
Expand All @@ -533,10 +526,6 @@ def perform_create(self, serializer):

@action(detail=False, methods=['post'], url_path='from-template')
def from_template(self, request):
"""
POST /api/cheatsheets/from-template/
Create cheat sheet from template
"""
template_id = request.data.get("template_id")
title = request.data.get("title", "Untitled")

Expand All @@ -559,10 +548,6 @@ def from_template(self, request):


class PracticeProblemViewSet(viewsets.ModelViewSet):
"""
CRUD API for Practice Problems
Get/Post/Put/Delete /api/problems/
"""
queryset = PracticeProblem.objects.all()
serializer_class = PracticeProblemSerializer

Expand All @@ -571,4 +556,4 @@ def get_queryset(self):
cheat_sheet_id = self.request.query_params.get('cheat_sheet')
if cheat_sheet_id:
queryset = queryset.filter(cheat_sheet=cheat_sheet_id)
return queryset
return queryset
Loading
Loading