From fc671ea5c375076925b7f57bc2d68f1496f3f197 Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 17:17:36 -0700 Subject: [PATCH 1/6] Save landscape orientation backend logic --- backend/api/latex_utils.py | 29 +++++++----- backend/api/views.py | 49 +++++++------------- frontend/src/components/CreateCheatSheet.jsx | 20 +++++++- 3 files changed, 52 insertions(+), 46 deletions(-) diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py index 6e85dc5..53ae527 100644 --- a/backend/api/latex_utils.py +++ b/backend/api/latex_utils.py @@ -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( @@ -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){5}^%\n?" ) @@ -130,27 +129,33 @@ def append_text_heading(lines, text): lines.append(r"\noindent " + text + r"\par") -def build_layout_comment_block(columns=2, font_size="10pt", margins="0.25in", spacing="large"): +def build_layout_comment_block(columns=2, font_size="10pt", margins="0.25in", spacing="large", 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=2, font_size="10pt", margins="0.25in", spacing="large"): +def build_dynamic_header(columns=2, font_size="10pt", margins="0.25in", spacing="large", 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) + + # Inject landscape orientation if selected + geometry_options = f"margin={margins}" + if orientation == "landscape": + geometry_options += ", landscape" header_lines = [ f"\\documentclass[{doc_class_size},fleqn]{{{doc_class}}}", - f"\\usepackage[margin={margins}]{{geometry}}", + f"\\usepackage[{geometry_options}]{{geometry}}", "\\usepackage{amsmath, amssymb}", "\\usepackage{enumitem}", "\\usepackage{multicol}", @@ -188,12 +193,12 @@ def build_dynamic_footer(columns=2): return "\n".join(footer_lines) -def normalize_latex_layout(content, columns=2, font_size="10pt", margins="0.25in", spacing="large"): +def normalize_latex_layout(content, columns=2, font_size="10pt", margins="0.25in", spacing="large", 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: @@ -219,18 +224,18 @@ def normalize_latex_layout(content, columns=2, font_size="10pt", margins="0.25in 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=2, font_size="10pt", margins="0.25in", spacing="large"): +def build_latex_for_formulas(selected_formulas, columns=2, font_size="10pt", margins="0.25in", spacing="large", 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"] @@ -238,7 +243,7 @@ def build_latex_for_formulas(selected_formulas, columns=2, font_size="10pt", mar 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 @@ -334,4 +339,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() \ No newline at end of file diff --git a/backend/api/views.py b/backend/api/views.py index f92e0cd..0ba1e2a 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -23,6 +23,7 @@ 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"} def is_valid_custom_pt(value, min_value, max_value): @@ -43,7 +44,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): @@ -57,8 +58,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 = "large" + + if orientation not in VALID_ORIENTATION: + orientation = "portrait" - return columns, font_size, margins, spacing + return columns, font_size, margins, spacing, orientation # ------------------------------------------------------------------ # API endpoints @@ -93,21 +97,18 @@ def get_classes(request): def generate_sheet(request): """ POST /api/generate-sheet/ - Accepts { "formulas": [...], "columns": 2, "font_size": "10pt", "margins": "0.25in", "spacing": "large" } - 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": "..." } """ selected = request.data.get("formulas", []) columns = request.data.get("columns", 2) font_size = request.data.get("font_size", "10pt") margins = request.data.get("margins", "0.25in") spacing = request.data.get("spacing", "large") + 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() @@ -154,7 +155,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}) @@ -163,10 +164,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") @@ -175,9 +172,10 @@ def compile_latex(request): font_size = request.data.get("font_size", "10pt") margins = request.data.get("margins", "0.25in") spacing = request.data.get("spacing", "large") - columns, font_size, margins, spacing = validate_layout_params(columns, font_size, margins, spacing) + orientation = request.data.get("orientation", "portrait") # <-- Extract orientation + + 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) content = cheatsheet.build_full_latex() @@ -185,7 +183,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({ @@ -195,6 +193,7 @@ def compile_latex(request): "font_size": font_size, "margins": margins, "spacing": spacing, + "orientation": orientation, }, }) @@ -233,10 +232,6 @@ def compile_latex(request): # ------------------------------------------------------------------ class TemplateViewSet(viewsets.ModelViewSet): - """ - CRUD API for Templates - Get/Post/Put/Delete /api/templates/ - """ queryset = Template.objects.all() serializer_class = TemplateSerializer @@ -249,10 +244,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] @@ -265,10 +256,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") @@ -291,10 +278,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 @@ -303,4 +286,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 \ No newline at end of file diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx index a7a8b1f..c60f431 100644 --- a/frontend/src/components/CreateCheatSheet.jsx +++ b/frontend/src/components/CreateCheatSheet.jsx @@ -522,7 +522,7 @@ const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSavi const FONT_SIZE_PRESETS = ['8pt', '9pt', '10pt', '11pt', '12pt']; const SPACING_PRESETS = ['tiny', 'small', 'medium', 'large']; -const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, setSpacing, margins, setMargins }) => { +const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, setSpacing, margins, setMargins, orientation, setOrientation }) => { const fontSizeMode = FONT_SIZE_PRESETS.includes(fontSize) ? fontSize : 'custom'; const spacingMode = SPACING_PRESETS.includes(spacing) ? spacing : 'custom'; @@ -610,6 +610,20 @@ const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, se + {/* NEW ORIENTATION CONTROL */} +
+ + +
+ {/* END NEW CONTROL */} ); @@ -648,6 +662,8 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) => setSpacing, margins, setMargins, + orientation, + setOrientation, pdfBlob, isGenerating, isCompiling, @@ -766,6 +782,8 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) => setSpacing={setSpacing} margins={margins} setMargins={setMargins} + orientation={orientation} + setOrientation={setOrientation} /> From 664489c10af8fb3a8b1c1607efce2c87c360773d Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 18:16:36 -0700 Subject: [PATCH 2/6] feat: add landscape orientation support and fix layout regex --- backend/api/latex_utils.py | 41 +++++++++++-------------------- backend/api/views.py | 40 ++++++------------------------ frontend/src/hooks/latex.js | 41 ++++++++++++++++++++++--------- frontend/src/hooks/latex.test.jsx | 7 ++++-- 4 files changed, 57 insertions(+), 72 deletions(-) diff --git a/backend/api/latex_utils.py b/backend/api/latex_utils.py index 78cbaa7..e186b59 100644 --- a/backend/api/latex_utils.py +++ b/backend/api/latex_utils.py @@ -46,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){5}^%\n?" + r"(?m)(?:^% @cheatsheet-layout .*\n)+^%\n?" ) @@ -129,11 +129,7 @@ def append_text_heading(lines, text): lines.append(r"\noindent " + text + r"\par") -<<<<<<< HEAD -def build_layout_comment_block(columns=2, font_size="10pt", margins="0.25in", spacing="large", orientation="portrait"): -======= -def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spacing="small"): ->>>>>>> af1ff138475768f9f924bbb5507570998035711a +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", @@ -144,11 +140,7 @@ def build_layout_comment_block(columns=4, font_size="9pt", margins="0.15in", spa ] -<<<<<<< HEAD -def build_dynamic_header(columns=2, font_size="10pt", margins="0.25in", spacing="large", orientation="portrait"): -======= -def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing="small"): ->>>>>>> af1ff138475768f9f924bbb5507570998035711a +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. """ @@ -156,18 +148,23 @@ def build_dynamic_header(columns=4, font_size="9pt", margins="0.15in", spacing=" spacing_values = get_spacing_values(spacing, font_size) doc_class, doc_class_size = get_document_class(font_size) - # Inject landscape orientation if selected - geometry_options = f"margin={margins}" + # 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": - geometry_options += ", 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"\\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=*}", @@ -201,11 +198,7 @@ def build_dynamic_footer(columns=2): return "\n".join(footer_lines) -<<<<<<< HEAD -def normalize_latex_layout(content, columns=2, font_size="10pt", margins="0.25in", spacing="large", orientation="portrait"): -======= -def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in", spacing="small"): ->>>>>>> af1ff138475768f9f924bbb5507570998035711a +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 @@ -242,11 +235,7 @@ def normalize_latex_layout(content, columns=4, font_size="9pt", margins="0.15in" return header + body + ("\n" if body else "") + footer -<<<<<<< HEAD -def build_latex_for_formulas(selected_formulas, columns=2, font_size="10pt", margins="0.25in", spacing="large", orientation="portrait"): -======= -def build_latex_for_formulas(selected_formulas, columns=4, font_size="9pt", margins="0.15in", spacing="small"): ->>>>>>> af1ff138475768f9f924bbb5507570998035711a +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. diff --git a/backend/api/views.py b/backend/api/views.py index f987a75..4be731a 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -37,15 +37,11 @@ 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"} -<<<<<<< HEAD VALID_ORIENTATION = {"portrait", "landscape"} -======= DEFAULT_COLUMNS = 4 DEFAULT_FONT_SIZE = "9pt" DEFAULT_SPACING = "small" DEFAULT_MARGINS = "0.15in" ->>>>>>> af1ff138475768f9f924bbb5507570998035711a - def is_valid_custom_pt(value, min_value, max_value): normalized = str(value or "").strip() @@ -78,14 +74,10 @@ def validate_layout_params(columns, font_size, margins, spacing, orientation="po margins = DEFAULT_MARGINS if spacing not in VALID_SPACING and not is_valid_custom_pt(spacing, 0, 6): -<<<<<<< HEAD - spacing = "large" - + spacing = DEFAULT_SPACING + if orientation not in VALID_ORIENTATION: orientation = "portrait" -======= - spacing = DEFAULT_SPACING ->>>>>>> af1ff138475768f9f924bbb5507570998035711a return columns, font_size, margins, spacing, orientation @@ -291,17 +283,7 @@ def get_classes(request): @api_view(["POST"]) def generate_sheet(request): """ - POST /api/generate-sheet/ -<<<<<<< HEAD - """ - selected = request.data.get("formulas", []) - columns = request.data.get("columns", 2) - font_size = request.data.get("font_size", "10pt") - margins = request.data.get("margins", "0.25in") - spacing = request.data.get("spacing", "large") - orientation = request.data.get("orientation", "portrait") -======= - 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": "..." } @@ -311,7 +293,7 @@ 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) ->>>>>>> af1ff138475768f9f924bbb5507570998035711a + orientation = request.data.get("orientation", "portrait") columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation) @@ -376,21 +358,13 @@ def compile_latex(request): content = request.data.get("content", "") cheat_sheet_id = request.data.get("cheat_sheet_id") normalize_only = is_truthy(request.data.get("normalize_only")) -<<<<<<< HEAD - columns = request.data.get("columns", 2) - font_size = request.data.get("font_size", "10pt") - margins = request.data.get("margins", "0.25in") - spacing = request.data.get("spacing", "large") - orientation = request.data.get("orientation", "portrait") # <-- Extract orientation - - columns, font_size, margins, spacing, orientation = validate_layout_params(columns, font_size, margins, spacing, orientation) -======= columns = request.data.get("columns", DEFAULT_COLUMNS) 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) ->>>>>>> af1ff138475768f9f924bbb5507570998035711a + 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: cheatsheet = get_object_or_404(CheatSheet, pk=cheat_sheet_id, user=request.user) diff --git a/frontend/src/hooks/latex.js b/frontend/src/hooks/latex.js index f5df0ea..9b1f13e 100644 --- a/frontend/src/hooks/latex.js +++ b/frontend/src/hooks/latex.js @@ -10,6 +10,7 @@ const DEFAULT_LAYOUT = { fontSize: '9pt', spacing: 'small', margins: '0.15in', + orientation: 'portrait', }; function getInitialContentSource(data) { @@ -84,6 +85,7 @@ export function useLatex(initialData) { const [fontSize, setFontSize] = useState(initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize); const [spacing, setSpacing] = useState(initialData?.spacing ?? DEFAULT_LAYOUT.spacing); const [margins, setMargins] = useState(initialData?.margins ?? DEFAULT_LAYOUT.margins); + const [orientation, setOrientation] = useState(initialData?.orientation ?? DEFAULT_LAYOUT.orientation); const [pdfBlob, setPdfBlob] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isCompiling, setIsCompiling] = useState(false); @@ -103,6 +105,7 @@ export function useLatex(initialData) { fontSize: initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData?.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData?.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData?.orientation ?? DEFAULT_LAYOUT.orientation, }); // Revoke the object URL when the component unmounts to prevent memory leaks @@ -172,11 +175,13 @@ export function useLatex(initialData) { setFontSize(saved.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(saved.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(saved.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(saved.orientation ?? DEFAULT_LAYOUT.orientation); lastCompiledLayoutRef.current = { columns: saved.columns ?? DEFAULT_LAYOUT.columns, fontSize: saved.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: saved.spacing ?? DEFAULT_LAYOUT.spacing, margins: saved.margins ?? DEFAULT_LAYOUT.margins, + orientation: saved.orientation ?? DEFAULT_LAYOUT.orientation, }; } else if (initialData) { initialLoaded.current = true; @@ -187,11 +192,13 @@ export function useLatex(initialData) { setFontSize(initialData.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(initialData.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(initialData.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(initialData.orientation ?? DEFAULT_LAYOUT.orientation); lastCompiledLayoutRef.current = { columns: initialData.columns ?? DEFAULT_LAYOUT.columns, fontSize: initialData.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData.orientation ?? DEFAULT_LAYOUT.orientation, }; } }, [initialData]); @@ -208,12 +215,12 @@ export function useLatex(initialData) { useEffect(() => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { - saveLatexStorage({ title, content, contentSource, columns, fontSize, spacing, margins }); + saveLatexStorage({ title, content, contentSource, columns, fontSize, spacing, margins, orientation }); }, SAVE_DEBOUNCE_MS); return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; - }, [title, content, contentSource, columns, fontSize, spacing, margins]); + }, [title, content, contentSource, columns, fontSize, spacing, margins, orientation]); const compileLatexContent = useCallback(async (latexContent, layoutOptions = {}) => { const response = await fetch('/api/compile/', { @@ -248,6 +255,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }), }); @@ -258,7 +266,7 @@ export function useLatex(initialData) { const data = await response.json(); return data.tex_code; - }, [columns, fontSize, spacing, margins]); + }, [columns, fontSize, spacing, margins, orientation]); const normalizeLatexContent = useCallback(async (latexContent) => { const response = await fetch('/api/compile/', { @@ -273,6 +281,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, normalize_only: true, }), }); @@ -284,13 +293,15 @@ export function useLatex(initialData) { const data = await response.json(); return data.tex_code || latexContent; - }, [authTokens, columns, fontSize, margins, spacing]); + }, [authTokens, columns, fontSize, margins, spacing, orientation]); const hasLayoutChanges = lastCompiledLayoutRef.current.columns !== columns || lastCompiledLayoutRef.current.fontSize !== fontSize || lastCompiledLayoutRef.current.spacing !== spacing || - lastCompiledLayoutRef.current.margins !== margins; + lastCompiledLayoutRef.current.margins !== margins || + lastCompiledLayoutRef.current.orientation !== orientation; + const canRegenerateFromSelections = !content.trim() || contentSource === 'generated'; const handleCompileOnly = useCallback(async (selectedList = []) => { @@ -328,8 +339,9 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }); - lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins }; + lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins, orientation }; setContentModified(false); } catch (error) { setCompileError(error.message); @@ -337,7 +349,7 @@ export function useLatex(initialData) { setIsCompiling(false); isCompilingRef.current = false; } - }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, generateLatexContent, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing]); + }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, generateLatexContent, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing, orientation]); useEffect(() => { if (!initialLoaded.current) return; @@ -372,7 +384,8 @@ export function useLatex(initialData) { columns: regenerateOptions.columns, font_size: regenerateOptions.fontSize, spacing: regenerateOptions.spacing, - margins: margins + margins: margins, + orientation: orientation }), }); if (response.ok) { @@ -401,8 +414,9 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }); - lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins }; + lastCompiledLayoutRef.current = { columns, fontSize, spacing, margins, orientation }; setContentModified(false); } catch (error) { setCompileError(error.message); @@ -410,7 +424,7 @@ export function useLatex(initialData) { setIsCompiling(false); isCompilingRef.current = false; } - }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing]); + }, [clearAutoCompileTimer, columns, compileLatexContent, content, fontSize, hasLayoutChanges, margins, normalizeLatexContent, saveToHistory, spacing, orientation]); useEffect(() => { if (!initialLoaded.current || hasRestoredPreviewRef.current) return; @@ -472,6 +486,7 @@ export function useLatex(initialData) { font_size: fontSize, spacing, margins, + orientation, }), }); if (!response.ok) { @@ -560,6 +575,7 @@ export function useLatex(initialData) { setFontSize(initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize); setSpacing(initialData?.spacing ?? DEFAULT_LAYOUT.spacing); setMargins(initialData?.margins ?? DEFAULT_LAYOUT.margins); + setOrientation(initialData?.orientation ?? DEFAULT_LAYOUT.orientation); setHistory([]); setHistoryIndex(-1); lastCompiledLayoutRef.current = { @@ -567,6 +583,7 @@ export function useLatex(initialData) { fontSize: initialData?.fontSize ?? DEFAULT_LAYOUT.fontSize, spacing: initialData?.spacing ?? DEFAULT_LAYOUT.spacing, margins: initialData?.margins ?? DEFAULT_LAYOUT.margins, + orientation: initialData?.orientation ?? DEFAULT_LAYOUT.orientation, }; if (pdfBlobUrlRef.current) { URL.revokeObjectURL(pdfBlobUrlRef.current); @@ -595,6 +612,8 @@ export function useLatex(initialData) { setSpacing, margins, setMargins, + orientation, + setOrientation, pdfBlob, isGenerating, isCompiling, @@ -612,4 +631,4 @@ export function useLatex(initialData) { handlePrintPDF, clearLatex }; -} +} \ No newline at end of file diff --git a/frontend/src/hooks/latex.test.jsx b/frontend/src/hooks/latex.test.jsx index bc850fd..522884a 100644 --- a/frontend/src/hooks/latex.test.jsx +++ b/frontend/src/hooks/latex.test.jsx @@ -45,6 +45,7 @@ describe('useLatex hook', () => { expect(result.current.fontSize).toBe('9pt'); expect(result.current.spacing).toBe('small'); expect(result.current.margins).toBe('0.15in'); + expect(result.current.orientation).toBe('portrait'); // <-- Added orientation default expect(result.current.pdfBlob).toBeNull(); expect(result.current.compileError).toBeNull(); }); @@ -56,7 +57,8 @@ describe('useLatex hook', () => { columns: 3, fontSize: '12pt', spacing: 'medium', - margins: '0.5in' + margins: '0.5in', + orientation: 'landscape' // <-- Added orientation custom data }; const { result } = renderHook(() => useLatex(initialData), { wrapper }); @@ -67,6 +69,7 @@ describe('useLatex hook', () => { expect(result.current.fontSize).toBe('12pt'); expect(result.current.spacing).toBe('medium'); expect(result.current.margins).toBe('0.5in'); + expect(result.current.orientation).toBe('landscape'); // <-- Added orientation assertion }); test('treats persisted generated sheets as safe to regenerate', () => { @@ -242,4 +245,4 @@ describe('useLatex hook', () => { expect(mockClick).toHaveBeenCalled(); expect(mockElement.download).toBe('FileTitle.tex'); }); -}); +}); \ No newline at end of file From de70d22135ba218b0e0a7da32e412c65f68810eb Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 18:39:42 -0700 Subject: [PATCH 3/6] test: update backend tests to match new orientation and letterpaper layout --- backend/api/tests.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/api/tests.py b/backend/api/tests.py index 0599af9..d98ff6b 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -36,7 +36,7 @@ def sample_template(db): subject="algebra", description="A test template", latex_content="\\section*{Test}\nHello World", - default_margins="0.5in", + default_margins="0.5in",assert "\\documentclass[8pt,fleqn]{extarticle}" in normalized default_columns=2, ) @@ -239,7 +239,7 @@ def test_normalize_latex_layout_rewraps_existing_document_with_current_settings( normalized = normalize_latex_layout(raw, columns=4, font_size="8pt", margins="0.5in", spacing="tiny") - 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 @@ -383,7 +383,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 @@ -402,6 +402,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 ──────────────────────────────────────────────────────── @@ -1375,6 +1376,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", ) @@ -1387,6 +1389,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 @@ -1395,8 +1398,9 @@ 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 - +def test_normalize_latex_layout_rewraps_existing_document_with_current_settings(self): # ── Auth Endpoint Tests ────────────────────────────────────────────── From 7a7af9e9dd59d1543e30ce65e80a3ff177c244ca Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 19:01:32 -0700 Subject: [PATCH 4/6] feat: add orientation field to CheatSheet model --- .../migrations/0008_cheatsheet_orientation.py | 18 ++++++++++++++++++ backend/api/models.py | 1 + 2 files changed, 19 insertions(+) create mode 100644 backend/api/migrations/0008_cheatsheet_orientation.py diff --git a/backend/api/migrations/0008_cheatsheet_orientation.py b/backend/api/migrations/0008_cheatsheet_orientation.py new file mode 100644 index 0000000..0f392d9 --- /dev/null +++ b/backend/api/migrations/0008_cheatsheet_orientation.py @@ -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), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index a16dea9..9d7e45d 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -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) From 5948d568f18909aa36b0ebbfdcec9d50e9b6f25a Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 19:06:38 -0700 Subject: [PATCH 5/6] fix: resolve syntax errors in backend tests --- backend/api/tests.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/api/tests.py b/backend/api/tests.py index d98ff6b..2812276 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -36,7 +36,7 @@ def sample_template(db): subject="algebra", description="A test template", latex_content="\\section*{Test}\nHello World", - default_margins="0.5in",assert "\\documentclass[8pt,fleqn]{extarticle}" in normalized + default_margins="0.5in", default_columns=2, ) @@ -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 @@ -237,7 +238,7 @@ 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,letterpaper]{extarticle}" in normalized assert "margin=0.5in" in normalized @@ -1400,7 +1401,7 @@ def test_compile_normalize_only_refreshes_layout_comments_from_request_values(se assert "% @cheatsheet-layout margins: 0.25in | change layout options up top to update margins" in tex assert "orientation: portrait" in tex -def test_normalize_latex_layout_rewraps_existing_document_with_current_settings(self): + # ── Auth Endpoint Tests ────────────────────────────────────────────── From 096616a020c093f703d926fc8f899a9e7bd32f76 Mon Sep 17 00:00:00 2001 From: Joey Lu Date: Tue, 5 May 2026 19:12:39 -0700 Subject: [PATCH 6/6] test: update remaining generate sheet tests for letterpaper --- backend/api/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/tests.py b/backend/api/tests.py index 2812276..3ab4cc0 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -1248,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): @@ -1263,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): @@ -1278,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):