'
+ )
+
+ # Assign annotation_id to each scheme if not already set.
+ # The main annotation path (generate_annotation_html_template) does this for
+ # config["annotation_schemes"], but phase schemas loaded from JSON files
+ # (consent, prestudy, etc.) arrive here without annotation_id set.
+ for idx, annotation_scheme in enumerate(annotation_schemas):
+ if "annotation_id" not in annotation_scheme:
+ annotation_scheme["annotation_id"] = idx
+
+ # Pre-allocate non-conflicting keybindings across all schemas
+ allocation = allocate_keybindings(annotation_schemas)
+ for annotation_scheme in annotation_schemas:
+ name = annotation_scheme.get("name", "")
+ if name in allocation:
+ annotation_scheme["_allocated_keys"] = allocation[name]
+
+ # Handle annotation layout generation for surveyflow phases.
+ # Only fall back to the global task_layout for the main annotation page
+ # (phase_name is None). Phase pages should not inherit the global
+ # annotation layout since it may expect annotation-only context.
+ if not task_layout_file and not phase_name:
+ task_layout_file = config.get("task_layout")
+
+ if task_layout_file:
+ # User provided a custom task layout file
+ logger.info(f"Using custom task layout file: {task_layout_file}")
+
+ # Resolve the path relative to the config file
+ task_layout_file = resolve_project_asset_path(config, task_layout_file)
+
+ # Read the custom task layout
+ with open(task_layout_file, "rt", encoding="utf-8") as f:
+ task_html_layout = "".join(f.readlines())
+
+ else:
+ # Use the dedicated annotation layout file system (auto-generated)
+ try:
+ layout_file_path = get_or_generate_annotation_layout(config, annotation_schemas, layout_name=phase_name)
+
+ # Read the generated layout file
+ with open(layout_file_path, "rt", encoding="utf-8") as f:
+ task_html_layout = "".join(f.readlines())
+
+ except Exception as e:
+ logger.warning(f"Failed to use dedicated layout file: {e}. Falling back to inline generation.")
+
+ # Fallback to inline generation
+ # Generate inline layout
+ schema_layouts = ""
+ for annotation_scheme in annotation_schemas:
+ schema_layout, keybindings = generate_schematic(annotation_scheme)
+ schema_layouts += schema_layout + "\n"
+
+ task_html_layout = f'
{schema_layouts}
'
+
+ cur_html_template = html_template.replace("{{ TASK_LAYOUT }}", task_html_layout)
+
+ # Add in a codebook link if the admin specified one
+ codebook_html = ""
+ if len(config.get("annotation_codebook_url", "")) > 0:
+ annotation_codebook = config["annotation_codebook_url"]
+ codebook_html = '
Annotation Codebook'
+ codebook_html = codebook_html.replace("{{annotation_codebook_url}}", annotation_codebook)
+
+ html_template = html_template.replace("{{annotation_codebook}}", codebook_html)
+
+ html_template = html_template.replace(
+ "{{annotation_task_name}}", config["annotation_task_name"]
+ )
+
+ _ = generate_statistics_sidebar(STATS_KEYS)
+ html_template = html_template.replace("{{statistics_nav}}", " ")
+
+ #
+ # Step 3, Fill in the annotation-specific pieces in the layout and save the page
+ #
+
+ logger.debug("Saw %d annotation scheme(s)" % len(annotation_schemas))
+
+ # Keep track of all the keybindings we have
+ all_keybindings = [("←", "Move backward"), ("→", "Move forward")]
+
+ # Do not display keybindings for the first and last page
+ if False:
+ if i == 0:
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings[1:])
+ cur_html_template = cur_html_template.replace(
+ '
Move backward',
+ '
Move backward',
+ )
+ elif i == len(annotation_schemas) - 1 or re.search("prestudy_fail", page):
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings[:-1])
+ cur_html_template = cur_html_template.replace(
+ '
Move forward',
+ '
Move forward',
+ )
+ else:
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings)
+
+ cur_html_template = cur_html_template.replace("{{keybindings}}", keybindings_desc)
+
+ # Cache the html as a template for use in flask server
+ site_name = (
+ "_".join(config["annotation_task_name"].split(" "))
+ + "-"
+ + "%s.html" % phase_name
+ )
+
+ # Create generated subdirectory within the templates directory
+ generated_dir = os.path.join(config["site_dir"], "generated")
+ if not os.path.exists(generated_dir):
+ os.makedirs(generated_dir)
+ logger.info(f"Created generated templates directory: {generated_dir}")
+
+ output_html_fname = os.path.join(generated_dir, site_name)
+
+ # Write the file
+ logger.debug("writing %s html to %s.html" % (phase_name, output_html_fname))
+ with open(output_html_fname, "wt", encoding="utf-8") as outf:
+ outf.write(cur_html_template)
+
+ return site_name #output_html_fname
\ No newline at end of file
diff --git a/potato/server_utils/html_sanitizer.py b/potato/server_utils/html_sanitizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7bfb3bf25f516e3d8416215f33ed5acd3ec7979
--- /dev/null
+++ b/potato/server_utils/html_sanitizer.py
@@ -0,0 +1,422 @@
+"""
+HTML Sanitizer Module
+
+Provides XSS-safe HTML sanitization for the annotation platform. This module
+allows legitimate span annotation HTML while blocking potentially dangerous
+elements and attributes.
+
+The sanitizer uses an allowlist approach - only explicitly permitted elements
+and attributes are kept, everything else is escaped or removed.
+
+Usage:
+ from potato.server_utils.html_sanitizer import sanitize_html
+
+ # In Jinja2 template:
+ {{ instance | sanitize_html }}
+"""
+
+import re
+import html
+import logging
+from typing import Set, Dict, List, Tuple
+from markupsafe import Markup
+
+logger = logging.getLogger(__name__)
+
+# Elements allowed in sanitized HTML
+ALLOWED_ELEMENTS: Set[str] = {
+ # Span annotations
+ 'span',
+ # Basic formatting (may be in source data)
+ 'b', 'i', 'u', 'strong', 'em', 'mark',
+ 's', 'del', 'ins',
+ # Line breaks and horizontal rules
+ 'br', 'hr', 'wbr',
+ # Dialogue/conversation layout elements
+ 'div',
+ # Structural elements for instructional content (Issue #120)
+ 'p',
+ 'ul', 'ol', 'li',
+ 'dl', 'dt', 'dd',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ # Tables for formatted content
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption',
+ # Inline semantics
+ 'sub', 'sup', 'small', 'code', 'pre', 'blockquote',
+ 'abbr', 'cite', 'kbd', 'samp', 'var',
+ # Collapsible sections
+ 'details', 'summary',
+ # Links (href sanitized against dangerous patterns)
+ 'a',
+ # Media and figures for instructional/survey content (Issue #129)
+ 'img', 'figure', 'figcaption',
+ # Audio/video media โ src checked against dangerous patterns; only safe
+ # attributes allowed (no event handlers). Potato is a multimedia tool.
+ 'video', 'audio', 'source', 'track',
+ # Ruby annotations for CJK text
+ 'ruby', 'rt', 'rp',
+}
+
+# Attributes allowed per element
+ALLOWED_ATTRIBUTES: Dict[str, Set[str]] = {
+ 'span': {
+ 'class',
+ 'style',
+ 'data-annotation-id',
+ 'data-label',
+ 'schema',
+ 'title',
+ },
+ 'div': {
+ 'class',
+ 'style',
+ 'data-speaker',
+ 'data-speaker-index',
+ },
+ 'mark': {'class', 'style'},
+ # Links โ href checked against dangerous patterns in _sanitize_attributes
+ 'a': {'href', 'title', 'target', 'rel'},
+ # Table elements
+ 'td': {'colspan', 'rowspan', 'style'},
+ 'th': {'colspan', 'rowspan', 'style', 'scope'},
+ 'table': {'class', 'style'},
+ # Ordered lists
+ 'ol': {'start', 'type'},
+ # Definition lists
+ 'dl': {'class', 'style'},
+ 'dt': {'class', 'style'},
+ 'dd': {'class', 'style'},
+ # Block-level elements that may need class/style
+ 'blockquote': {'class', 'style'},
+ 'p': {'class', 'style'},
+ 'pre': {'class', 'style'},
+ 'code': {'class'},
+ # Collapsible sections
+ 'details': {'class', 'style', 'open'},
+ 'summary': {'class', 'style'},
+ # Abbreviations with tooltip
+ 'abbr': {'title'},
+ # Deletion/insertion with optional metadata
+ 'del': {'datetime', 'cite'},
+ 'ins': {'datetime', 'cite'},
+ # Table captions
+ 'caption': {'class', 'style'},
+ # Images โ src checked against dangerous patterns like href
+ 'img': {'src', 'alt', 'title', 'style', 'width', 'height'},
+ # Audio/video media โ src checked against dangerous patterns; boolean
+ # attributes (controls/autoplay/loop/muted) carry no script risk.
+ 'video': {'src', 'controls', 'width', 'height', 'style', 'poster',
+ 'preload', 'autoplay', 'loop', 'muted', 'playsinline'},
+ 'audio': {'src', 'controls', 'preload', 'autoplay', 'loop', 'muted'},
+ 'source': {'src', 'type', 'srcset', 'media'},
+ 'track': {'src', 'kind', 'srclang', 'label', 'default'},
+ # Figures and captions for instructional content
+ 'figure': {'class', 'style'},
+ 'figcaption': {'class', 'style'},
+ # Most elements get no attributes
+ '*': set(),
+}
+
+# Allowed CSS properties in style attributes
+ALLOWED_CSS_PROPERTIES: Set[str] = {
+ 'background-color',
+ 'color',
+ 'font-weight',
+ 'font-style',
+ 'font-family',
+ 'font-size',
+ 'text-decoration',
+ 'text-align',
+ 'line-height',
+ # Layout properties for dialogue/pairwise display
+ 'display',
+ 'width',
+ 'max-width',
+ 'padding',
+ 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
+ 'margin',
+ 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
+ 'box-sizing',
+ 'vertical-align',
+ 'gap',
+ # List styling
+ 'list-style-type',
+ # Borders
+ 'border',
+ 'border-radius',
+ 'border-collapse',
+}
+
+# Valueless boolean attributes โ emitted as a bare name when present without a
+# value (e.g.