Not to worry - Seal comes with a built in AI Copilot to write code for you. Navigate to the 'Copilot' tab on the right hand side, and start chatting with the AI to describe what you need.
The AI agent has access to your current code, so you can ask it to help you debug specific issues or add to the existing code.
Once you're happy with the code, click on 'Accept code' to use it in your Script.
Note that the generated code may not fully be fit for your purpose - we recommend checking over the results.
The Seal Module
The Seal module is a Python package for interacting programatically with the Seal platform.
It is automatically available in every Script's Python environment - you don't need to import it manually.
The Seal module consists of a collection of methods on the seal object:
Returns the entire json blob of data for that entity. The entity id can be copied from the entity's URL in Seal - it's the final section of the url, after the entity title.
Just passing an entity_id returns the latest version or draft of the entity.
The entity id can be copied from the entity's URL in Seal - it's the final section of the url, after the entity title.
To get a specific version of an entity, include the version as a second argument. You can also pass an entity ref object instead.
Since it returns the whole json blob for the entity, you can get the entity's fields via entity["fields"] , or a specific field via entity["fields"]["fieldName"] .
seal.get_entity_active_version(entity_id)
Gets the data for the active version of the given entity. If no active version is found (ie the entity has never been published), an error will be thrown.
seal.get_containing_entity()
Gets the entity the script card is embedded in.
seal.get_entities_by_title(title)
Gets (non-archived) entities by their title (string). Note that exact titles are matched by default. Since multiple entities can share the same title, this returns an array. You can also search for titles containing the search string with exact=False.
seal.search_entities(query_config)
Gets entities by searching with a query config object (dict). See the API documentation for more details on the query config schema.
Making an entity editable
seal.make_entity_editable(entity_id)
Used to make an entity editable (ie create a new draft). Returns the entire json blob of data for the new draft entity.
Reverting entity drafts
seal.revert_entities(entity_ids)
Revert multiple entity drafts to their previous published versions. All entities must be in EDITABLE status and have a previous version. Up to 500 entities can be reverted at once.
Parameters:
entity_ids: List of entity IDs to revert (maximum 500)
Returns an array of the reverted entities.
seal.revert_containing_entity()
Revert the entity the script is embedded in to its previous published version. The entity must be in EDITABLE status and have a previous version.
Returns an array containing the reverted entity.
Validating entities
Scripts can be used to run checks before an entity is published. A script can be added as a check on a type or template for its templates or instances.
seal.get_validating_entity()
Gets the entity a script is validating.
When a validation script is run, it is classed as passing if no errors are thrown. Any of the following methods will throw an error if the assertion is not met:
Method
Checks that
validation.assertEqual(a, b)
a == b
validation.assertNotEqual(a, b)
a != b
validation.assertTrue(x)
bool(x) is True
validation.assertFalse(x)
bool(x) is False
validation.assertIs(a, b)
a is b
validation.assertIsNot(a, b)
a is not b
validation.assertIsNone(x)
x is None
validation.assertIsNotNone(x)
x is not None
validation.assertIn(a, b)
a in b
validation.assertNotIn(a, b)
a not in b
validation.assertIsInstance(a, b)
isinstance(a, b)
validation.assertNotIsInstance(a, b)
not isinstance(a, b)
User-defined errors can also be thrown to fail a validation script.
seal.throw_error(message)
Throws an error with a user-provided message.
Archiving an entity
seal.archive_entity(entity_id, archive)
Archives or unarchives an entity. The archive parameter should be a boolean: True to archive, False to recover. Returns the updated entity data.
seal.archive_entities(entity_ids)
Archives multiple entities. Must pass in an array of entity_id values. Returns the ids that were archived.
Converting an Instance to a Template
seal.convert_instance_to_template(entity_id)
Converts an instance entity into a template entity. The instance must be editable (a draft) to be converted. Returns the entire json blob of data for the new template entity.
Creates a new change set containing the provided entities. Returns information about the created change set including id, index, name, status, description and entityRefs.
Getting tags
seal.get_tags_for_entity(entity_id)
Gets the tags on an entity.
seal.get_tags(tag)
Gets the tags on an entity the script is embedded in. Can only be run from a script card.
Adding tags
seal.add_tag_to_entity(entity_id, tag)
Adds a tag to an entity. If no tag matching the input tag is found in the organisation, a new tag will be created.
seal.add_tag(tag)
Adds a tag to the entity the script is embedded in. Can only be run from a script card.
Deleting tags
seal.delete_tag(tag_name)
Remove a tag by name from the entity within which the script is embedded.
seal.delete_tag_from_entity(entity_id, tag_name)
Remove a tag by name in any entity.
Setting assignees
seal.set_assignees(entity_id, assignees)
Set the assignees for an entity. The assignees parameter should be a list of strings where each item is either an email address or a user id.
Gets all workflow tasks associated with a workflow entity. Returns a list of dictionaries, each representing a full workflow task object. Each task includes a completedAt timestamp if the task has been completed, or null if the task is not completed.
seal.get_workflow_tasks_for_containing_entity()
Gets all workflow tasks for the entity that a script is embedded in. Can only be run from a script card. Each task includes a completedAt timestamp if the task has been completed.
Gets all workflow entities that have tasks referencing the specified entity either as a template or instance. Returns an array of full workflow entities. If no entity version is provided, workflows referencing the draft entity are returned.
Gets all workflow entities that have tasks referencing the entity the script is embedded in. Can only be run from a script card. Uses the specific version of the containing entity.
Returns information about the change set the given entity belongs to. This information includes id, index, name, status and description. By default, we find the change set for the latest draft of the entity (if it exists).
To find the change set that a specific version of an entity belongs to, include the version as a second argument. You can also pass an entity ref object instead.
seal.get_change_set_for_containing_entity()
Gets the change set for the entity that a script is embedded in. Can only be run from a script card.
Gets the out of spec fields for the provided entity. By default, the latest version or draft of the entity is used.
To view the out of spec fields from a specific version of an entity, include the version as a second argument. You can also pass an entity ref object instead.
Handling files
seal.download_file(file_entity_id, ?version)
The file is downloaded to the local filesystem of the virtual machine instance.
The value returned is the local file path as a string.
Submit instances to a submission field in the surrounding entity.
Submit one or more instances to an instance submission field, with optional initial field values in a dataframe. If a column is called 'title' or 'Title', it will set the instances' titles of setting the values of a field called 'title' or 'Title').
You can optionally specify a number of empty instances to create.
If the submission field refers to a type, a template_ref to a template of the type must be provided.
You can also submit data to a submission table in any entity:
Placeholder columns are additional fields that can be added to all rows in a submission table. These columns are automatically added to existing instances in the submission field.
Update the configuration of a placeholder column in the entity the script is embedded in. Configuration parameters are keyword-only and optional. Can only be run from a script card.
version: (Optional, keyword-only) Specific version of the entity
card_ids: (Optional, keyword-only) List of specific card ids to run on the page
Run all action buttons and script cards embedded in an entity with page content. You can optionally pass in the card_ids argument to specify which scripts in particular should be run. These IDs can be found in the page content of the entity (see the Entity Schema for more details).
Getting live backlinks
seal.get_live_backlinks(entity_id)
Returns the ids of all entities whose live data includes a reference to any version of the requested entity.
Getting trigger info
seal.get_trigger_info()
If the script is being run as a trigger, this method returns an object containing context
about the trigger run: trigger_id and triggered_by_entity_id.
Importing packages
Seal comes with many common Python packages pre-installed. If there are other python packages you regularly require, please contact Seal support.
These packages are automatically imported in every Script, so don't need to be manually imported:
altair (also aliased as alt)
Chart (alias of altair.Chart)
pandas (also aliased as pd)
Available packages to import include:
altair
annotated-types
attrs
blinker
cachetools
certifi
cffi
charset-normalizer
click
cloudevents
contourpy
cryptography
cycler
deprecation
et-xmlfile
flask
fonttools
functions-framework
gcloud
google-api-core
google-auth
google-cloud-appengine-logging
google-cloud-audit-log
google-cloud-core
google-cloud-error-reporting
google-cloud-logging
google-cloud-storage
google-crc32c
google-resumable-media
googleapis-common-protos
grpc-google-iam-v1
grpcio
grpcio-status
gunicorn
httplib2
idna
itsdangerous
jinja2
joblib
jsonschema
jsonschema-specifications
jwcrypto
kiwisolver
lxml
markupsafe
matplotlib
numpy
oauth2client
openpyxl
packaging
pandas
pillow
proto-plus
protobuf
pyasn1
pyasn1-modules
pycorn
pycparser
pycryptodome
pydantic
pydantic-core
pyparsing
pyrebase4
python-dateutil
python-docx
python-jwt
pytz
referencing
requests
requests-toolbelt
rpds-py
rsa
scikit-learn
scipy
setuptools
six
tabulate
threadpoolctl
toolz
typing-extensions
urllib3
vl-convert-python
watchdog
werkzeug
xlrd
To temporarily install a package when running the script:
import subprocess
subprocess.check_output("pip install {name of package}".split())
Examples
Creating and Linking Charts
This example script would be embedded in an entity containing a SUBMISSION field, with your chart data, and a REFERENCE field to embed the generated chart in.
import pandas as pd
import altair as alt
# Name of the chart entity to create
CHART_TITLE = "Growth Rate"
# Your type entity with content type: 'Chart'
# Find or create the type from org setting -> types
CHART_TYPE_NAME = "Chart"
# The containing entity REFERENCE field to append the generated file entity to
REFERENCE_FIELD_NAME = "Charts"
# Submission field name containing your chart data
SUBMISSION_FIELD_NAME = "Data"
# Submission column name to plot on the X axis
SUBMISSION_X_COLUMN = "Time"
# Submission column name to plot on the Y axis
SUBMISSION_Y_COLUMN = "Growth"
# Extract data from submission field
entity = seal.get_containing_entity()
submission_field = entity["fields"].get(SUBMISSION_FIELD_NAME, {})
submitted_refs = submission_field.get("value", [])
# Collect data from submitted entities
data = []
for ref in submitted_refs:
submitted_entity = seal.get_entity(ref=ref)
fields = submitted_entity["fields"]
timepoint = fields.get(SUBMISSION_X_COLUMN, {}).get("value")
growth_rate = fields.get(SUBMISSION_Y_COLUMN, {}).get("value")
if timepoint and growth_rate:
data.append({SUBMISSION_X_COLUMN: timepoint, SUBMISSION_Y_COLUMN: growth_rate})
# Create DataFrame and chart
df = pd.DataFrame(data)
chart = alt.Chart(df).mark_line(point=True).encode(x=SUBMISSION_X_COLUMN, y=SUBMISSION_Y_COLUMN)
# Create chart entity and link to reference field
chart_id = seal.create_chart(chart, title=CHART_TITLE, type_title=CHART_TYPE)
existing_refs = entity["fields"].get(REFERENCE_FIELD_NAME, {}).get("value", []) or []
updated_refs = existing_refs + [{"id": chart_id, "version": None}]
seal.update_field_value(REFERENCE_FIELD_NAME, updated_refs)
Creating Labels
Create PDF labels from entity data with configurable layouts. This method requires the Label creation blueprint to be installed. To setup this up, see Creating labels blueprint.
seal.create_label_v1()
This script uses the ReportLab PDF Library to create labels with text, barcodes (Code128/Code39/QR), images, and geometric elements. It automatically operates in Preview Mode when embedded in a configuration entity (for testing) or Trigger Mode when triggered by entity updates (for automatic label generation). The configuration is stored in entities with layout elements defined in submission tables.
Complete Label Generation Script
import io
import os
import time
from reportlab.graphics.barcode import code39, code128, qr # type: ignore
from reportlab.lib import colors # type: ignore
from reportlab.lib.colors import black, blue, gray, red, white # type: ignore
from reportlab.lib.units import cm, inch, mm # type: ignore
from reportlab.pdfbase.pdfmetrics import stringWidth # type: ignore
from reportlab.pdfgen import canvas # type: ignore
from reportlab.platypus import Table, TableStyle # type: ignore
UNIT_OBJECTS = {"cm": cm, "mm": mm, "inch": inch}
COLOR_MAP = {
"Black": black,
"Gray": gray,
"Blue": blue,
"Red": red,
"White": white,
"None": None,
}
DEFAULT_FONT_SIZE = 10
DEFAULT_FONT_NAME = "Helvetica"
# Entity properties support (copied from compute.ts)
ENTITY_PROPERTIES = [
"CREATED_AT",
"CREATED_BY",
"EDITORS",
"ID",
"INDEX",
"LAST_EDITED_AT",
"LAST_EDITED_BY",
"TEMPLATE_INDEX",
"TITLE",
"TYPE_INDEX",
"SUBMITTED_FROM",
]
def is_entity_property(property_name):
"""Check if a property name is an entity property (case-insensitive)"""
return property_name.upper() in ENTITY_PROPERTIES
def get_field_or_property_value(entity, column_name):
"""Get value from entity field or property (case-insensitive for properties)"""
if is_entity_property(column_name):
property_name = column_name.upper()
if property_name == "TITLE":
return entity.get("title", "")
elif property_name == "ID":
return entity.get("id", "")
elif property_name == "CREATED_AT":
return entity.get("createdAt", "")
elif property_name == "CREATED_BY":
return entity.get("createdBy", "")
elif property_name == "EDITORS":
return entity.get("editors", "")
elif property_name == "INDEX":
return entity.get("index", "")
elif property_name == "LAST_EDITED_AT":
return entity.get("lastEditedAt", "")
elif property_name == "LAST_EDITED_BY":
return entity.get("lastEditedBy", "")
elif property_name == "TEMPLATE_INDEX":
return entity.get("templateIndex", "")
elif property_name == "TYPE_INDEX":
return entity.get("typeIndex", "")
elif property_name == "SUBMITTED_FROM":
return entity.get("submittedFrom", "")
else:
# hard to maintain let's fallback to lowercase
return entity.get(column_name.lower(), "")
else:
# Handle field values using existing function
fields = entity.get("fields", {})
return extract_field_value(fields, column_name, "")
def extract_field_value(field_dict, field_name, default_value):
"""Extract field value handling SELECT arrays and whitespace"""
field_value = field_dict.get(field_name, {}).get("value", default_value)
if isinstance(field_value, list) and field_value:
result = field_value[0]
elif isinstance(field_value, list):
result = default_value
else:
result = field_value if field_value is not None else default_value
return result
def collect_all_entity_references(entity_data, config):
"""Collect all entity references from REFERENCE and INSTANCE_SUBMISSION fields to batch fetch them"""
entity_refs_to_fetch = set()
# Look through all entity data for entity references
for field_name, field_value in entity_data.items():
if isinstance(field_value, list):
for item in field_value:
if isinstance(item, dict) and "id" in item:
entity_refs_to_fetch.add(item["id"])
return list(entity_refs_to_fetch)
def collect_entity_references_from_entity(entity):
"""Collect entity references from an entity's REFERENCE and INSTANCE_SUBMISSION fields"""
entity_refs_to_fetch = set()
fields = entity.get("fields", {})
for field_name, field_info in fields.items():
field_type = field_info.get("type", "")
field_value = field_info.get("value", "")
# Look for REFERENCE and INSTANCE_SUBMISSION fields
if field_type in ["REFERENCE", "INSTANCE_SUBMISSION"] and isinstance(
field_value, list
):
for ref in field_value:
if isinstance(ref, dict) and "id" in ref:
entity_refs_to_fetch.add(ref["id"])
return list(entity_refs_to_fetch)
def batch_fetch_entities(entity_ids, entity_cache):
"""Fetch multiple entities and add to cache"""
if not entity_ids:
return entity_cache
for entity_id in entity_ids:
if entity_id not in entity_cache:
try:
entity = seal.get_entity(entity_id=entity_id)
entity_cache[entity_id] = entity
entity_title = entity.get("title", "Untitled")
print(f"Fetched entity: {entity_title}")
except Exception as e:
print(f"Failed to fetch entity {entity_id}: {e}")
entity_cache[entity_id] = None
return entity_cache
def find_config_for_template(config_template_id, target_template_id):
"""Find configuration instance(s) that support the target template ID"""
if not config_template_id:
return {"matches": [], "error": "No configuration template ID provided"}
# Search for instances created from the configuration template
search_query = {"filters": {"kind": ["INSTANCE"], "template": [config_template_id]}}
try:
config_instances = seal.search_entities(search_query)
matching_configs = []
for instance in config_instances:
try:
instance_entity = seal.get_entity(entity_id=instance["id"])
instance_fields = instance_entity.get("fields", {})
# Check the Templates reference field
templates_refs = instance_fields.get("Templates", {}).get("value", [])
if templates_refs and isinstance(templates_refs, list):
supported_template_ids = [
ref.get("id", "") for ref in templates_refs
]
if target_template_id in supported_template_ids:
config_title = instance_entity.get(
"title", f"Untitled Config ({instance_entity['id']})"
)
matching_configs.append(
{"id": instance_entity["id"], "title": config_title}
)
except Exception as e:
print(f"Failed to process config instance {instance['id']}: {str(e)}")
continue
if len(matching_configs) == 0:
return {
"matches": [],
"error": f"No configuration found for template '{target_template_id}'",
}
elif len(matching_configs) == 1:
print(
f"Found configuration '{matching_configs[0]['title']}' for template '{target_template_id}'"
)
return {"matches": matching_configs, "error": None}
else:
# Multiple matches - this is an error condition
config_titles = [config["title"] for config in matching_configs]
config_list = ", ".join(config_titles)
print(
f"WARNING: Found {len(matching_configs)} configurations for template "
f"'{target_template_id}': {config_list}"
)
error_message = (
f"Multiple configurations found for template '{target_template_id}': "
f"{config_list}. Please delete unused configurations to avoid ambiguity."
)
return {
"matches": matching_configs,
"error": error_message,
}
except Exception as e:
return {
"matches": [],
"error": f"Failed to search for configuration instances: {str(e)}",
}
def main():
"""Main entry point with tri-mode detection"""
try:
config = load_script_configuration()
try:
trigger_info = seal.get_trigger_info()
print("Running in TRIGGER MODE")
run_trigger_mode(config, trigger_info.triggered_by_entity_id)
except Exception as e:
if "not called as a trigger" in str(e):
# Determine if Preview or Manual mode based on containing entity
mode_info = config.get("_mode_info", {})
if mode_info.get("mode") == "preview":
print("Running in PREVIEW MODE")
run_preview_mode(config)
elif mode_info.get("mode") == "manual":
print("Running in MANUAL MODE")
run_manual_mode(config, mode_info.get("containing_entity_id"))
else:
raise Exception("Could not determine script mode")
else:
raise e
except Exception as e:
print(f"Script execution failed: {str(e)}")
raise
def load_script_configuration():
"""Load configuration from referenced configuration entity"""
script_entity_id = seal.entity_id
script_entity = seal.get_entity(entity_id=script_entity_id)
script_fields = script_entity.get("fields", {})
# Determine which configuration entity to use
config_entity = None
config_field_name = None
try:
# Try to get trigger info to determine the triggering entity template
trigger_info = seal.get_trigger_info()
triggering_entity_id = trigger_info.triggered_by_entity_id
triggering_entity = seal.get_entity(entity_id=triggering_entity_id)
triggering_template_ref = triggering_entity.get("sourceInfo", {}).get(
"template", {}
)
triggering_template_id = triggering_template_ref.get("id", "")
if not triggering_template_id:
raise Exception("Triggering entity has no template ID")
# Get the Label Configuration Template reference from script entity
script_entity_id = seal.entity_id
script_entity = seal.get_entity(entity_id=script_entity_id)
script_fields = script_entity.get("fields", {})
config_template_refs = script_fields.get(
"Label Configuration Template", {}
).get("value", [])
if (
not config_template_refs
or not isinstance(config_template_refs, list)
or len(config_template_refs) == 0
):
raise Exception(
"No Label Configuration Template found in script. Please add a "
"'Label Configuration Template' reference field to the script entity."
)
config_template_id = config_template_refs[0].get("id", "")
if not config_template_id:
raise Exception("Label Configuration Template reference is invalid")
# Search for configuration instance that supports this template
config_result = find_config_for_template(
config_template_id, triggering_template_id
)
if config_result["error"]:
error_details = config_result["error"]
raise Exception(
f"Configuration lookup failed for template '{triggering_template_id}': "
f"{error_details}\n"
f"Please ensure you have exactly one configuration instance from the "
f"Label Configuration Template with '{triggering_template_id}' in the "
f"'Templates' field."
)
config_entity_id = config_result["matches"][0]["id"]
config_field_name = f"Trigger Mode (template {triggering_template_id})"
config_entity = seal.get_entity(entity_id=config_entity_id)
except Exception as e:
try:
containing_entity = seal.get_containing_entity()
containing_fields = containing_entity.get("fields", {})
# Check if containing entity has "Label Elements" field to determine mode
if "Label Elements" in containing_fields:
# Preview mode: containing entity is a configuration entity
config_entity = containing_entity
config_field_name = "Preview Mode"
mode = "preview"
else:
# Manual mode: containing entity is a regular entity that wants a label
config_entity = None # Will be determined from Label Configuration Template instances
config_field_name = "Manual Mode"
mode = "manual"
containing_entity_id = containing_entity["id"]
if mode == "manual":
# Manual mode: find configuration using containing entity's template
containing_template_ref = containing_entity.get("sourceInfo", {}).get(
"template", {}
)
containing_template_id = containing_template_ref.get("id", "")
if not containing_template_id:
raise Exception("Manual mode: Containing entity has no template ID")
# Get Label Configuration Template from script entity to find matching config
script_entity_id = seal.entity_id
script_entity = seal.get_entity(entity_id=script_entity_id)
script_fields = script_entity.get("fields", {})
config_template_refs = script_fields.get(
"Label Configuration Template", {}
).get("value", [])
if (
not config_template_refs
or not isinstance(config_template_refs, list)
or len(config_template_refs) == 0
):
raise Exception(
"Manual mode failed: No 'Label Configuration Template' reference field found in script entity. "
"Please add a 'Label Configuration Template' reference field to the script."
)
config_template_id = config_template_refs[0].get("id", "")
if not config_template_id:
raise Exception(
"Manual mode failed: Label Configuration Template reference is invalid"
)
# Find configuration instance that supports containing entity's template
config_result = find_config_for_template(
config_template_id, containing_template_id
)
if config_result["error"]:
error_details = config_result["error"]
raise Exception(
f"Manual mode failed: Configuration lookup failed for template "
f"'{containing_template_id}': {error_details}\n"
f"Please ensure you have exactly one configuration instance from the "
f"Label Configuration Template with '{containing_template_id}' in the "
f"'Templates' field."
)
config_entity_id = config_result["matches"][0]["id"]
config_entity = seal.get_entity(entity_id=config_entity_id)
config_field_name = f"Manual Mode (template {containing_template_id})"
print(
f"Manual mode: Found configuration for template '{containing_template_id}'"
)
# Validate that the script has a properly configured Label Configuration Template (Preview mode only)
if mode == "preview":
preview_entities_refs = (
config_entity.get("fields", {})
.get("Preview Entities", {})
.get("value", [])
)
if preview_entities_refs:
# Check if script entity has Label Configuration Template field
script_entity_id = seal.entity_id
script_entity = seal.get_entity(entity_id=script_entity_id)
script_fields = script_entity.get("fields", {})
config_template_refs = script_fields.get(
"Label Configuration Template", {}
).get("value", [])
if (
not config_template_refs
or not isinstance(config_template_refs, list)
or len(config_template_refs) == 0
):
print(
"INFO: No 'Label Configuration Template' reference field found in script entity. "
"Preview will work, but this script won't trigger in trigger. "
"To enable trigger mode, add a 'Label Configuration Template' "
"reference field to the script entity."
)
config_field_name = "Preview Mode (no trigger configuration)"
# Skip further validation since there's no Label Configuration Template
preview_validation_complete = True
else:
config_template_id = config_template_refs[0].get("id", "")
preview_validation_complete = False
# Check that preview entities' templates are covered in Label Configuration Template
if not preview_validation_complete:
first_preview_entity = seal.get_entity(
entity_id=preview_entities_refs[0]["id"]
)
preview_template_ref = first_preview_entity.get(
"sourceInfo", {}
).get("template", {})
preview_template_id = preview_template_ref.get("id", "")
if preview_template_id:
# Check if Label Configuration Template instances support this template
config_result = find_config_for_template(
config_template_id, preview_template_id
)
if config_result["error"]:
error_details = config_result["error"]
print(
f"WARNING: Configuration lookup failed for preview entity "
f"template '{preview_template_id}': {error_details}. "
f"This preview will generate successfully, but entities with this "
f"template will NOT trigger the script in trigger mode. To enable "
f"trigger mode, ensure you have exactly one configuration instance "
f"from the Label Configuration Template with '{preview_template_id}' "
f"in the 'Templates' field."
)
config_field_name = (
"Preview Mode (template not configured for trigger)"
)
elif (
config_result["matches"][0]["id"] != config_entity["id"]
):
found_config_id = config_result["matches"][0]["id"]
found_config_title = config_result["matches"][0][
"title"
]
raise Exception(
f"Script validation failed: Label Configuration Template instances "
f"contain template '{preview_template_id}' but point to config "
f"'{found_config_title}' (ID: {found_config_id}) instead of this "
f"configuration entity '{config_entity['id']}'. Please update the "
f"configuration instance."
)
else:
config_field_name = "Preview Mode (validated Label Configuration Template)"
else:
print(
"Preview entities found but first entity has no template - "
"skipping script validation"
)
else:
print("No preview entities configured - skipping script validation")
config_field_name = "Preview Mode"
except Exception as preview_error:
raise Exception(
f"Cannot determine configuration entity.\n"
f"Trigger mode failed: {str(e)}\n"
f"Embedded mode failed: {str(preview_error)}"
)
if not config_entity:
raise Exception("Could not load configuration entity")
print(f"Using configuration: {config_field_name} (ID: {config_entity['id']})")
fields = config_entity.get("fields", {})
units = extract_field_value(fields, "Units", "cm")
label_width = extract_field_value(fields, "Label Width", 10.0)
label_height = extract_field_value(fields, "Label Height", 5.0)
print(f"Label configuration: {label_width} x {label_height} {units}")
config = {
"Label Width": label_width,
"Label Height": label_height,
"Default Font": extract_field_value(fields, "Default Font", DEFAULT_FONT_NAME),
"Default Font Size": extract_field_value(
fields, "Default Font Size", DEFAULT_FONT_SIZE
),
"Units": units,
"Preview Entities": fields.get("Preview Entities", {}).get("value", []),
"Show Grid Lines": False, # Default to False, will be overridden in preview mode if needed
}
# Load layout elements from Label Elements reference field
layout_elements_field = fields.get("Label Elements", {})
layout_elements = []
if layout_elements_field and "value" in layout_elements_field:
element_refs = layout_elements_field["value"]
if not element_refs:
print("Label Elements field is empty - no elements will be rendered")
for element_ref in element_refs:
try:
element_entity = seal.get_entity(entity_id=element_ref["id"])
element_fields = element_entity.get("fields", {})
# Extract element configuration with defaults
element = {
"Element Type": extract_field_value(
element_fields, "Element Type", "Text"
),
"Text": extract_field_value(
element_fields, "Text", ""
), # For Text elements
"Field Or Property Name": extract_field_value(
element_fields, "Field Or Property Name", ""
), # For all other elements
"Column Names": extract_field_value(
element_fields, "Column Names", ""
), # For Table elements - comma-separated text field
"Prefix Text": extract_field_value(
element_fields, "Prefix Text", ""
),
"X Position": extract_field_value(element_fields, "X Position", 0),
"Y Position": extract_field_value(element_fields, "Y Position", 0),
"Width": extract_field_value(element_fields, "Width", 2.0),
"Height": extract_field_value(element_fields, "Height", 1.0),
"Font Name": extract_field_value(
element_fields, "Font Name", DEFAULT_FONT_NAME
),
"Font Size": extract_field_value(
element_fields, "Font Size", None
), # None = use default
"Alignment": extract_field_value(
element_fields, "Alignment", "Top-Left"
), # Universal alignment
"Rotation (°)": extract_field_value(
element_fields, "Rotation (°)", 0
), # Rotation in degrees
"Line Width": extract_field_value(element_fields, "Line Width", 1),
"Stroke Color": extract_field_value(
element_fields, "Stroke Color", "Black"
),
}
layout_elements.append(element)
except Exception as e:
print(f"Failed to load layout element {element_ref['id']}: {str(e)}")
continue
config["Label Elements"] = layout_elements
config["Configuration Entity ID"] = config_entity["id"]
# Add mode information for tri-mode detection
if "mode" in locals():
config["_mode_info"] = {
"mode": mode,
"containing_entity_id": containing_entity_id if mode == "manual" else None,
}
# In preview mode, check for "Show Gridlines" field
if mode == "preview":
show_gridlines_field = fields.get(
"Show Gridlines (only shown in preview mode)", {"value": False}
)
config["Show Grid Lines"] = show_gridlines_field.get("value")
if config["Show Grid Lines"]:
print("Grid lines enabled via 'Show Gridlines' field")
print(
f"Loaded {len(layout_elements)} label elements using config '{config_field_name}'"
)
return config
def convert_coordinates(x, y, canvas_obj):
"""
Convert from top-left coordinate system to ReportLab's bottom-left system
User coordinates: (0,0) = top-left, Y increases downward
ReportLab: (0,0) = bottom-left, Y increases upward
"""
height = canvas_obj._label_height
return x, height - y
def calculate_aligned_position(x, y, width, height, alignment):
"""
Calculate actual drawing position based on alignment anchor point
Args:
x, y: User-specified coordinates
width, height: Element dimensions (in same units as x, y)
alignment: "Top-Left", "Center", "Top-Right", "Bottom-Left", "Bottom-Right" (case-insensitive)
Returns:
(adjusted_x, adjusted_y): Actual position for drawing
"""
# Make alignment detection case-insensitive
alignment_upper = alignment.upper()
if alignment_upper == "CENTER" or alignment_upper == "CENTRE":
return x - width / 2, y - height / 2
elif alignment_upper == "TOP-RIGHT":
return x - width, y
elif alignment_upper == "BOTTOM-LEFT":
return x, y - height
elif alignment_upper == "BOTTOM-RIGHT":
return x - width, y - height
else: # "TOP-LEFT" or any other value defaults to top-left
return x, y
def apply_rotation_to_canvas(canvas_obj, rotation_degrees, anchor_x, anchor_y):
"""
Apply rotation around a specific anchor point
Args:
canvas_obj: ReportLab canvas object
rotation_degrees: Rotation angle in degrees (clockwise)
anchor_x, anchor_y: Point to rotate around (in ReportLab coordinates)
"""
if rotation_degrees != 0:
# Save current state
canvas_obj.saveState()
# Translate to anchor point, rotate, then translate back
canvas_obj.translate(anchor_x, anchor_y)
canvas_obj.rotate(rotation_degrees)
canvas_obj.translate(-anchor_x, -anchor_y)
return True # Indicates rotation was applied
return False # No rotation applied
def render_element_with_rotation(canvas_obj, element, x, y, unit_obj, render_callback):
"""
Base render function that handles common rotation setup/teardown for all element types
Args:
canvas_obj: ReportLab canvas object
element: Element configuration dict
x, y: User coordinates
unit_obj: ReportLab unit object (cm, mm, inch)
render_callback: Function that performs the actual rendering
Signature: render_callback(canvas_obj, element, x, y, unit_obj, anchor_coords)
where anchor_coords is (anchor_x_rl, anchor_y_rl)
"""
rotation = element.get("Rotation (°)", 0)
if isinstance(rotation, list) and rotation:
rotation = rotation[0]
if not isinstance(rotation, (int, float)):
rotation = 0
anchor_x = x * unit_obj
anchor_y = y * unit_obj
anchor_x_rl, anchor_y_rl = convert_coordinates(anchor_x, anchor_y, canvas_obj)
rotation_applied = apply_rotation_to_canvas(
canvas_obj, rotation, anchor_x_rl, anchor_y_rl
)
try:
render_callback(canvas_obj, element, x, y, unit_obj, (anchor_x_rl, anchor_y_rl))
finally:
if rotation_applied:
canvas_obj.restoreState()
def check_page_break(y_position, canvas_obj, min_space=50):
"""
Check if we need a page break and create one if necessary
Returns the new Y position (reset to top if page break occurred)
"""
if y_position < min_space: # Less than min_space points from bottom
canvas_obj.showPage()
# After page break, we're back to top-left coordinates
return canvas_obj._label_height - 20 # Start 20 points from top
return y_position
def draw_grid_lines(canvas_obj, label_width, label_height, units, unit_obj):
"""
Draw grid lines to help with element positioning (preview mode only)
Args:
canvas_obj: ReportLab canvas object
label_width: Label width in user units
label_height: Label height in user units
units: Unit type ("cm", "mm", "inch")
unit_obj: ReportLab unit object
"""
# Grid colors (subtle so they don't interfere with content)
major_grid_color = (0.7, 0.7, 0.7) # Light gray for major lines
minor_grid_color = (0.9, 0.9, 0.9) # Very light gray for minor lines
# Define grid spacing based on units
if units == "cm":
major_spacing = 1.0 # Every 1 cm
minor_spacing = 0.5 # Every 0.5 cm
elif units == "mm":
major_spacing = 10.0 # Every 10 mm
minor_spacing = 5.0 # Every 5 mm
elif units == "inch":
major_spacing = 1.0 # Every 1 inch
minor_spacing = 0.25 # Every 0.25 inch
else:
major_spacing = 1.0
minor_spacing = 0.5
# Convert to points
width_points = label_width * unit_obj
height_points = label_height * unit_obj
canvas_obj.saveState()
# Draw minor grid lines first (so they appear behind major lines)
canvas_obj.setStrokeColorRGB(*minor_grid_color)
canvas_obj.setLineWidth(0.25)
# Vertical minor lines
x = minor_spacing
while x < label_width:
x_points = x * unit_obj
canvas_obj.line(x_points, 0, x_points, height_points)
x += minor_spacing
# Horizontal minor lines
y = minor_spacing
while y < label_height:
y_points = height_points - (y * unit_obj) # Convert to ReportLab coordinates
canvas_obj.line(0, y_points, width_points, y_points)
y += minor_spacing
# Draw major grid lines
canvas_obj.setStrokeColorRGB(*major_grid_color)
canvas_obj.setLineWidth(0.5)
# Vertical major lines with labels
x = major_spacing
while x <= label_width:
x_points = x * unit_obj
canvas_obj.line(x_points, 0, x_points, height_points)
# Add coordinate labels at the top
canvas_obj.setFont("Helvetica", 6)
canvas_obj.setFillColorRGB(*major_grid_color)
label_text = f"{int(x)}" if x == int(x) else f"{x:.1f}"
canvas_obj.drawCentredString(x_points, height_points - 10, label_text)
x += major_spacing
# Horizontal major lines with labels
y = major_spacing
while y <= label_height:
y_points = height_points - (y * unit_obj) # Convert to ReportLab coordinates
canvas_obj.line(0, y_points, width_points, y_points)
# Add coordinate labels on the left
canvas_obj.setFont("Helvetica", 6)
canvas_obj.setFillColorRGB(*major_grid_color)
label_text = f"{int(y)}" if y == int(y) else f"{y:.1f}"
canvas_obj.drawString(3, y_points - 2, label_text)
y += major_spacing
# Draw border
canvas_obj.setStrokeColorRGB(*major_grid_color)
canvas_obj.setLineWidth(1.0)
canvas_obj.rect(0, 0, width_points, height_points, stroke=1, fill=0)
# Add unit labels in corners
canvas_obj.setFont("Helvetica", 8)
canvas_obj.setFillColorRGB(*major_grid_color)
canvas_obj.drawString(5, height_points - 15, f"Preview Mode Grid: {units}")
canvas_obj.drawRightString(
width_points - 5, 5, f"Size: {label_width} x {label_height} {units}"
)
canvas_obj.restoreState()
def create_label_pdf(config, entity_data):
"""
SHARED label generation core used by both modes
Args:
config: Dict of configuration values (width, height, label elements, etc.)
entity_data: Dict of entity field values
Returns:
PDF buffer ready for upload
"""
print("Starting PDF generation...")
# Initialize entity cache for Table elements
if "_entity_cache" not in config:
config["_entity_cache"] = {}
# Pre-populate cache with entities from reference fields
source_entity = entity_data.get("_source_entity", {})
entity_refs_to_fetch = collect_entity_references_from_entity(source_entity)
if entity_refs_to_fetch:
config["_entity_cache"] = batch_fetch_entities(
entity_refs_to_fetch, config["_entity_cache"]
)
units = config.get("Units", "cm")
unit_obj = UNIT_OBJECTS[units]
width = config.get("Label Width", 10.0) * unit_obj
height = config.get("Label Height", 5.0) * unit_obj
pdf_buffer = io.BytesIO()
canvas_obj = canvas.Canvas(pdf_buffer, pagesize=(width, height))
# Store dimensions for coordinate conversion
canvas_obj._label_width = width
canvas_obj._label_height = height
# Draw grid lines in preview mode if enabled
if config.get("Show Grid Lines", False):
draw_grid_lines(
canvas_obj,
config.get("Label Width", 10.0),
config.get("Label Height", 5.0),
units,
unit_obj,
)
layout_elements = config.get("Label Elements", [])
print(f"Processing {len(layout_elements)} label elements")
for i, element in enumerate(layout_elements):
try:
render_layout_element(canvas_obj, element, entity_data, unit_obj, config)
except Exception as e:
print(f"Failed to render layout element {i + 1}: {str(e)}")
continue
canvas_obj.save()
pdf_buffer.seek(0)
return pdf_buffer
def render_layout_element(canvas_obj, element, entity_data, unit_obj, config):
"""Render a single layout element on the canvas"""
element_type = element.get("Element Type", "").strip()
# Handle Table element type
if element_type == "Table":
x_pos = element.get("X Position", 0)
y_pos = element.get("Y Position", 0)
alignment = element.get("Alignment", "Top-Left")
if isinstance(alignment, list) and alignment:
alignment = alignment[0].strip()
elif isinstance(alignment, str):
alignment = alignment.strip()
else:
alignment = "Top-Left"
render_table_element(
canvas_obj, element, entity_data, x_pos, y_pos, unit_obj, config, alignment
)
return
if element_type == "Text":
value_or_field_name = element.get("Text", "").strip()
else:
value_or_field_name = element.get("Field Or Property Name", "").strip()
x_pos = element.get("X Position", 0)
y_pos = element.get("Y Position", 0)
alignment = element.get("Alignment", "Top-Left")
if isinstance(alignment, list) and alignment:
alignment = alignment[0].strip()
elif isinstance(alignment, str):
alignment = alignment.strip()
else:
alignment = "Top-Left"
x = x_pos * unit_obj
y = y_pos * unit_obj
# Convert from top-left to bottom-left coordinates
x_final, y_final = convert_coordinates(x, y, canvas_obj)
if element_type == "Page Break":
canvas_obj.showPage()
return
prefix_text = element.get("Prefix Text", "")
if element_type == "Text":
base_value = value_or_field_name
elif element_type == "Field":
# Handle both entity properties and field values
if is_entity_property(value_or_field_name):
# Get from entity properties (need the full entity)
source_entity = entity_data.get("_source_entity", {})
base_value = get_field_or_property_value(source_entity, value_or_field_name)
else:
# Get from entity fields using utility (supports case-insensitive properties)
source_entity = entity_data.get("_source_entity", {})
base_value = get_field_or_property_value(source_entity, value_or_field_name)
if not base_value and value_or_field_name:
print(
f"Field or property '{value_or_field_name}' not found in entity data, skipping element"
)
return
elif element_type in ["Barcode Code128", "QR Code", "Barcode Code39"]:
# Get from entity properties (need the full entity)
source_entity = entity_data.get("_source_entity", {})
base_value = get_field_or_property_value(source_entity, value_or_field_name)
if not base_value and value_or_field_name:
print(
f"Field or property '{value_or_field_name}' not found in entity data, skipping element"
)
return
# For barcodes, use base value without prefix (barcodes need clean data)
final_value = base_value
elif element_type == "Image":
source_entity = entity_data.get("_source_entity", {})
image_refs = get_field_or_property_value(source_entity, value_or_field_name)
if not image_refs:
print(
f"Image reference field or property '{value_or_field_name}' not found, skipping element"
)
return
final_value = image_refs
elif element_type == "Border":
base_value = ""
final_value = ""
else:
print(f"Unknown element type: {element_type}")
return
if element_type in ["Text", "Field"]:
final_value = f"{prefix_text}{base_value}" if prefix_text else base_value
# Render the appropriate element
if element_type in ["Text", "Field"]:
render_text_element(
canvas_obj, element, final_value, x_pos, y_pos, unit_obj, config, alignment
)
elif element_type in ["Barcode Code128", "QR Code", "Barcode Code39"]:
render_barcode_element(
canvas_obj, element, final_value, x_pos, y_pos, unit_obj, alignment
)
elif element_type == "Image":
render_image_element(
canvas_obj, element, final_value, x_pos, y_pos, unit_obj, alignment
)
elif element_type == "Border":
render_rectangle_element(canvas_obj, element, x_pos, y_pos, unit_obj, alignment)
def render_text_element(
canvas_obj, element, field_value, x, y, unit_obj, config, alignment
):
"""Render text element with universal alignment and rotation support"""
def render_text_callback(canvas_obj, element, x, y, unit_obj, anchor_coords):
"""Specific text rendering logic"""
font_name = element.get("Font Name (optional)", None)
if font_name is None:
font_name = config.get("Default Font", DEFAULT_FONT_NAME)
font_size = element.get("Font Size (optional)", None)
if font_size is None:
font_size = config.get("Default Font Size", DEFAULT_FONT_SIZE)
stroke_color = COLOR_MAP.get(element.get("Stroke Color", "Black"), black)
alignment_upper = alignment.upper()
if "RIGHT" in alignment_upper:
text_alignment = "Right"
elif "CENTER" in alignment_upper or "CENTRE" in alignment_upper:
text_alignment = "Center"
else:
text_alignment = "Left"
text_str = str(field_value)
# Split text by newlines for multiline support
text_lines = text_str.split("\n")
# Calculate dimensions for all lines
max_text_width = 0
for line in text_lines:
line_width = stringWidth(line, font_name, font_size)
max_text_width = max(max_text_width, line_width)
# Calculate total text height (font size + line spacing for each line)
line_spacing = font_size * 0.2 # 20% of font size for line spacing
total_text_height = (
len(text_lines) * font_size + (len(text_lines) - 1) * line_spacing
)
# Use the same alignment approach as images - calculate aligned position and stick with it
aligned_x, aligned_y = calculate_aligned_position(
x, y, max_text_width / unit_obj, total_text_height / unit_obj, alignment
)
# Convert aligned position to ReportLab coordinates
aligned_x_final = aligned_x * unit_obj
aligned_y_final = aligned_y * unit_obj
aligned_x_rl, aligned_y_rl = convert_coordinates(
aligned_x_final, aligned_y_final, canvas_obj
)
canvas_obj.setFont(font_name, font_size)
if stroke_color:
canvas_obj.setFillColor(stroke_color)
# For center alignment, we need the original coordinates (where user wants the center)
if text_alignment == "Center":
original_x_rl = x * unit_obj # Original x coordinate in ReportLab units
# Draw each line separately
for line_index, line in enumerate(text_lines):
# Calculate Y position for this line (ReportLab draws from baseline)
line_y = (
aligned_y_rl
- font_size * 0.8
- (line_index * (font_size + line_spacing))
)
if text_alignment == "Center":
# For center: use the original coordinates as the center point (avoid double-centering)
canvas_obj.drawCentredString(original_x_rl, line_y, line)
elif text_alignment == "Right":
# For right: use the right edge of the aligned text area
right_x = aligned_x_rl + max_text_width
canvas_obj.drawRightString(right_x, line_y, line)
else:
# For left: use the left edge of the aligned text area
canvas_obj.drawString(aligned_x_rl, line_y, line)
render_element_with_rotation(
canvas_obj, element, x, y, unit_obj, render_text_callback
)
def render_barcode_element(canvas_obj, element, field_value, x, y, unit_obj, alignment):
"""Render barcode element with universal alignment and rotation support"""
def render_barcode_callback(canvas_obj, element, x, y, unit_obj, anchor_coords):
"""Specific barcode rendering logic"""
element_type = element.get("Element Type")
desired_width = element.get("Width", 2.0) * unit_obj
height = element.get("Height", 1.0) * unit_obj
try:
canvas_obj.saveState()
canvas_obj.setStrokeColor(black)
canvas_obj.setFillColor(black)
if element_type == "Barcode Code128":
# Calculate appropriate barWidth for desired total width
# For Code128, typical character width is about 11 units, so estimate total units needed
estimated_units = (
len(str(field_value)) * 11 + 35
) # 35 for start/stop/checksum
target_bar_width = desired_width / estimated_units
# Ensure minimum bar width for readability (at least 0.5 points)
min_bar_width = 0.5
bar_width = max(target_bar_width, min_bar_width)
barcode = code128.Code128(
value=str(field_value),
barHeight=height,
barWidth=bar_width,
humanReadable=True,
quiet=0, # Disable quiet zones for precise alignment
lquiet=0,
rquiet=0,
)
actual_barcode_width = barcode.width
draw_x, draw_y = calculate_aligned_position(
x, y, actual_barcode_width / unit_obj, height / unit_obj, alignment
)
draw_x_final = draw_x * unit_obj
draw_y_final = draw_y * unit_obj
draw_x_rl, draw_y_rl = convert_coordinates(
draw_x_final, draw_y_final, canvas_obj
)
# Adjust Y coordinate: ReportLab draws barcodes from bottom-left, we want top-left positioning
actual_barcode_y = draw_y_rl - height
barcode.drawOn(canvas_obj, draw_x_rl, actual_barcode_y)
elif element_type == "Barcode Code39":
# Calculate appropriate barWidth for desired total width
# For Code39, each character is about 13 units wide
estimated_units = len(str(field_value)) * 13 + 25 # 25 for start/stop
target_bar_width = desired_width / estimated_units
# Ensure minimum bar width for readability
min_bar_width = 0.5
bar_width = max(target_bar_width, min_bar_width)
barcode = code39.Standard39(
value=str(field_value),
barHeight=height,
barWidth=bar_width,
humanReadable=True,
quiet=0, # Disable quiet zones for precise alignment
lquiet=0,
rquiet=0,
)
actual_barcode_width = barcode.width
draw_x, draw_y = calculate_aligned_position(
x, y, actual_barcode_width / unit_obj, height / unit_obj, alignment
)
draw_x_final = draw_x * unit_obj
draw_y_final = draw_y * unit_obj
draw_x_rl, draw_y_rl = convert_coordinates(
draw_x_final, draw_y_final, canvas_obj
)
# Adjust Y coordinate: ReportLab draws barcodes from bottom-left, we want top-left positioning
actual_barcode_y = draw_y_rl - height
barcode.drawOn(canvas_obj, draw_x_rl, actual_barcode_y)
elif element_type == "QR Code":
# QR codes use the width and height directly
draw_x, draw_y = calculate_aligned_position(
x, y, desired_width / unit_obj, height / unit_obj, alignment
)
draw_x_final = draw_x * unit_obj
draw_y_final = draw_y * unit_obj
draw_x_rl, draw_y_rl = convert_coordinates(
draw_x_final, draw_y_final, canvas_obj
)
qr_code = qr.QrCodeWidget(str(field_value))
qr_code.barWidth = desired_width
qr_code.barHeight = height
from reportlab.graphics import renderPDF # type: ignore
from reportlab.graphics.shapes import Drawing # type: ignore
drawing = Drawing(desired_width, height)
drawing.add(qr_code)
# Adjust Y coordinate: ReportLab draws from bottom-left, we want top-left positioning
qr_y = draw_y_rl - height
renderPDF.draw(drawing, canvas_obj, draw_x_rl, qr_y)
canvas_obj.restoreState()
except Exception as e:
print(f"Failed to render barcode {element_type}: {str(e)}")
canvas_obj.restoreState()
fallback_x, fallback_y = calculate_aligned_position(
x, y, 100 / unit_obj, 8 / unit_obj, alignment
)
fallback_x_final = fallback_x * unit_obj
fallback_y_final = fallback_y * unit_obj
fallback_x_rl, fallback_y_rl = convert_coordinates(
fallback_x_final, fallback_y_final, canvas_obj
)
canvas_obj.setFont("Helvetica", 8)
fallback_text_y = fallback_y_rl - 8 * 0.8
canvas_obj.drawString(
fallback_x_rl, fallback_text_y, f"Barcode: {field_value}"
)
render_element_with_rotation(
canvas_obj, element, x, y, unit_obj, render_barcode_callback
)
def render_rectangle_element(canvas_obj, element, x, y, unit_obj, alignment):
"""Render border element with universal alignment and rotation support"""
def render_rectangle_callback(canvas_obj, element, x, y, unit_obj, anchor_coords):
"""Specific rectangle/border rendering logic"""
width = element.get("Width", 2.0) * unit_obj
height = element.get("Height", 1.0) * unit_obj
line_width = element.get("Line Width", 1)
stroke_color = COLOR_MAP.get(element.get("Stroke Color", "Black"))
aligned_x, aligned_y = calculate_aligned_position(
x, y, width / unit_obj, height / unit_obj, alignment
)
x_final = aligned_x * unit_obj
y_final = aligned_y * unit_obj
x_rl, y_rl = convert_coordinates(x_final, y_final, canvas_obj)
canvas_obj.setLineWidth(line_width)
if stroke_color:
canvas_obj.setStrokeColor(stroke_color)
# Adjust Y coordinate: ReportLab draws rectangles from bottom-left, we want top-left positioning
rect_y = y_rl - height
stroke_flag = 1 if stroke_color else 0
canvas_obj.rect(x_rl, rect_y, width, height, stroke=stroke_flag, fill=0)
render_element_with_rotation(
canvas_obj, element, x, y, unit_obj, render_rectangle_callback
)
def render_table_element(
canvas_obj, element, entity_data, x, y, unit_obj, config, alignment
):
"""Render table using ReportLab's Table class"""
def render_table_callback(canvas_obj, element, x, y, unit_obj, anchor_coords):
"""Specific table rendering logic"""
# Get table configuration
field_name = element.get("Field Or Property Name", "").strip()
column_names_text = element.get("Column Names", "").strip()
print(f"Column Names: {column_names_text}")
# Parse comma-separated column names into a list
if column_names_text:
column_names = [
name.strip() for name in column_names_text.split(",") if name.strip()
]
else:
column_names = []
if not field_name or not column_names:
return
# Get entity references from the specified field
# For table elements, we need the FULL array of references, not just the first one
source_entity = entity_data.get("_source_entity", {})
if is_entity_property(field_name):
entity_refs = get_field_or_property_value(source_entity, field_name)
else:
fields = source_entity.get("fields", {})
entity_refs = fields.get(field_name, {}).get("value", [])
if not entity_refs:
print(f"DEBUG: No entity references found in field '{field_name}'")
return
entity_cache = config.get("_entity_cache", {})
entity_ids_to_fetch = []
for ref in entity_refs:
if isinstance(ref, dict) and "id" in ref:
entity_id = ref["id"]
if entity_id not in entity_cache:
entity_ids_to_fetch.append(entity_id)
# Batch fetch any missing entities
if entity_ids_to_fetch:
entity_cache = batch_fetch_entities(entity_ids_to_fetch, entity_cache)
config["_entity_cache"] = entity_cache
# Build table data
table_data = []
# Header row - use column names as headers
headers = column_names[:] # Copy the list
table_data.append(headers)
# Data rows
for ref in entity_refs:
if isinstance(ref, dict) and "id" in ref:
entity_id = ref["id"]
if entity_id in entity_cache and entity_cache[entity_id]:
entity = entity_cache[entity_id]
row = []
for column_name in column_names:
value = get_field_or_property_value(entity, column_name)
row.append(str(value) if value else "")
table_data.append(row)
if len(table_data) <= 1: # Only headers
return
# Get font size for the table - use element font size or fall back to global default
table_font_size = element.get("Font Size")
if table_font_size is None:
table_font_size = config.get("Default Font Size", DEFAULT_FONT_SIZE)
if isinstance(table_font_size, list) and table_font_size:
table_font_size = table_font_size[0]
if not isinstance(table_font_size, (int, float)) or table_font_size <= 0:
table_font_size = config.get("Default Font Size", DEFAULT_FONT_SIZE)
# Get font name for the table - use element font name or fall back to global default
table_font_name = element.get("Font Name (optional)")
if table_font_name is None:
table_font_name = config.get("Default Font", DEFAULT_FONT_NAME)
# Get bold version of the font for headers
if table_font_name == "Helvetica":
table_font_bold = "Helvetica-Bold"
elif table_font_name == "Times-Roman":
table_font_bold = "Times-Bold"
elif table_font_name == "Courier":
table_font_bold = "Courier-Bold"
else:
# For unknown fonts, try adding -Bold or fall back to the same font
table_font_bold = (
f"{table_font_name}-Bold"
if not table_font_name.endswith("-Bold")
else table_font_name
)
# Calculate dimensions first - needed for column width and row height calculation
cell_padding = table_font_size * 0.3
border_width = table_font_size * 0.05 # 5% of font size for border thickness
row_height = (
table_font_size * 1.8
) # 180% of font size for row height (extra space for vertical centering)
min_col_widths = []
for col_index in range(len(column_names)):
max_width = 0
for row in table_data:
if col_index < len(row):
cell_text = str(row[col_index])
# Use header font for header row, data font for data rows
font_name = (
table_font_bold if row == table_data[0] else table_font_name
)
text_width = stringWidth(cell_text, font_name, table_font_size)
max_width = max(max_width, text_width)
# Add padding (both sides) plus some extra margin for large fonts
padding_multiplier = 0.5 # More padding for large fonts
min_width = (
max_width + (cell_padding * 2) + (table_font_size * padding_multiplier)
)
min_col_widths.append(min_width)
# Create explicit row heights for proper vertical alignment
row_heights = [row_height] * len(table_data) # Same height for all rows
# Create ReportLab Table with calculated column widths AND explicit row heights
table = Table(table_data, colWidths=min_col_widths, rowHeights=row_heights)
# Build complete style commands list including row heights
style_commands = [
# Header row styling - background and font first
("BACKGROUND", (0, 0), (-1, 0), colors.gray),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), table_font_bold),
("FONTSIZE", (0, 0), (-1, 0), table_font_size),
# Data rows styling - background and font
("BACKGROUND", (0, 1), (-1, -1), colors.white),
("TEXTCOLOR", (0, 1), (-1, -1), colors.black),
("FONTNAME", (0, 1), (-1, -1), table_font_name),
("FONTSIZE", (0, 1), (-1, -1), table_font_size),
# Row backgrounds for readability - alternating white and light gray
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, (0.9, 0.9, 0.9)]),
# Grid and borders - thickness relative to font size
("GRID", (0, 0), (-1, -1), border_width, colors.black),
# Alignment commands LAST - so they take precedence
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), # Vertical center
("ALIGN", (0, 0), (-1, 0), "CENTER"), # Header row center
("ALIGN", (0, 1), (-1, -1), "CENTER"), # Data rows center
]
# Note: Row heights are now set in Table constructor, not via MINHEIGHT style commands
# This ensures proper vertical alignment with VALIGN
table_style = TableStyle(style_commands)
table.setStyle(table_style)
# CRITICAL: Wrap the table to calculate natural dimensions (ReportLab requirement)
canvas_width = canvas_obj._label_width
canvas_height = canvas_obj._label_height
try:
actual_table_width, actual_table_height = table.wrap(
canvas_width, canvas_height
)
except (ValueError, TypeError):
# Fallback: calculate dimensions from our explicit column widths and row count
actual_table_width = sum(min_col_widths)
actual_table_height = len(table_data) * row_height
# Calculate position with alignment using ACTUAL table dimensions
aligned_x, aligned_y = calculate_aligned_position(
x,
y,
actual_table_width / unit_obj,
actual_table_height / unit_obj,
alignment,
)
final_x = aligned_x * unit_obj
final_y = aligned_y * unit_obj
final_x_rl, final_y_rl = convert_coordinates(final_x, final_y, canvas_obj)
# Draw table (ReportLab tables draw from bottom-left)
table_y = final_y_rl - actual_table_height
table.drawOn(canvas_obj, final_x_rl, table_y)
# Use existing rotation framework
# Note: render_table_callback accesses entity_data and config from closure
render_element_with_rotation(
canvas_obj, element, x, y, unit_obj, render_table_callback
)
def render_image_element(canvas_obj, element, image_refs, x, y, unit_obj, alignment):
"""Render image element with universal alignment and rotation support"""
def render_image_callback(canvas_obj, element, x, y, unit_obj, anchor_coords):
"""Specific image rendering logic"""
width = element.get("Width", 2.0) * unit_obj
height = element.get("Height", 1.0) * unit_obj
aligned_x, aligned_y = calculate_aligned_position(
x, y, width / unit_obj, height / unit_obj, alignment
)
x_final = aligned_x * unit_obj
y_final = aligned_y * unit_obj
x_rl, y_rl = convert_coordinates(x_final, y_final, canvas_obj)
try:
if isinstance(image_refs, list) and image_refs:
image_ref = image_refs[0]
elif isinstance(image_refs, dict):
image_ref = image_refs
else:
print(f"Invalid image reference format: {image_refs}")
return
image_entity_id = image_ref.get("id", "")
if not image_entity_id:
print("No image entity ID found in reference")
return
if image_ref.get("version"):
temp_path = seal.download_file(
file_entity_id=image_entity_id, version=image_ref["version"]
)
else:
temp_path = seal.download_file(file_entity_id=image_entity_id)
try:
# Adjust Y coordinate: ReportLab draws images from bottom-left, we want top-left positioning
image_y = y_rl - height
canvas_obj.drawImage(
temp_path, x_rl, image_y, width=width, height=height, mask="auto"
)
finally:
try:
os.unlink(temp_path)
except Exception as cleanup_error:
print(f"Failed to clean up downloaded file: {cleanup_error}")
except Exception as e:
print(f"Failed to render image: {str(e)}")
canvas_obj.setFont("Helvetica", 8)
fallback_y = y_rl - 8 * 0.8
canvas_obj.drawString(x_rl, fallback_y, "[Image Error]")
render_element_with_rotation(
canvas_obj, element, x, y, unit_obj, render_image_callback
)
def extract_entity_fields(entity):
"""Extract all field values from an entity for label generation"""
entity_data = {}
fields = entity.get("fields", {})
for field_name, field_info in fields.items():
field_value = field_info.get("value", "")
field_type = field_info.get("type", "")
if isinstance(field_value, list):
if (
field_value
and isinstance(field_value[0], dict)
and "id" in field_value[0]
):
if field_type in ["REFERENCE", "INSTANCE_SUBMISSION"]:
# Keep entity references for Table elements
entity_data[field_name] = field_value
else:
entity_data[field_name] = ", ".join(
[ref.get("id", "") for ref in field_value]
)
elif field_value:
entity_data[field_name] = ", ".join([str(item) for item in field_value])
else:
entity_data[field_name] = ""
elif isinstance(field_value, dict):
entity_data[field_name] = str(field_value.get("value", field_value))
else:
entity_data[field_name] = (
str(field_value) if field_value is not None else ""
)
# Store source entity for property access
entity_data["_source_entity"] = entity
return entity_data
def run_preview_mode(config):
"""Generate Labels for testing"""
preview_entities_refs = config.get("Preview Entities", [])
if not preview_entities_refs:
print("No preview entities configured")
return
preview_labels = []
for entity_ref in preview_entities_refs:
try:
print(f"Generating preview for entity: {entity_ref['id']}")
entity = seal.get_entity(entity_id=entity_ref["id"])
label_file = generate_and_upload_label(entity, config, mode="Preview")
preview_labels.append(label_file)
print(f"Preview label created successfully: {label_file['id']}")
except Exception as e:
print(f"Failed to generate preview for entity {entity_ref['id']}: {str(e)}")
continue
update_preview_references(preview_labels, config)
print(f"Generated {len(preview_labels)} Labels")
def run_trigger_mode(config, source_entity_id):
"""Generate label for triggering entity"""
try:
entity = seal.get_entity(entity_id=source_entity_id)
entity_data = extract_entity_fields(entity)
pdf_buffer = create_label_pdf(config, entity_data)
create_or_update_trigger_label(pdf_buffer, entity, config)
print(f"Label generated for entity: {entity.get('title')}")
except Exception as e:
print(f"Failed to generate trigger label: {str(e)}")
raise
def run_manual_mode(config, containing_entity_id):
"""Generate label for containing entity and try to update Labels field"""
try:
entity = seal.get_entity(entity_id=containing_entity_id)
label_file = generate_and_upload_label(entity, config, mode="Manual")
# Try to update Labels field on the containing entity
try:
update_manual_labels_preview(containing_entity_id, label_file)
print(
f"Manual label generated and added to Labels for entity: {entity.get('title')}"
)
except Exception as field_error:
print(
f"Manual label generated successfully, but failed to update Labels field: {str(field_error)}"
)
print(f"Label file created with ID: {label_file['id']}")
except Exception as e:
print(f"Failed to generate manual label: {str(e)}")
raise
def generate_and_upload_label(entity, config, mode="Preview"):
"""Shared logic to generate PDF and upload label for an entity"""
entity_data = extract_entity_fields(entity)
pdf_buffer = create_label_pdf(config, entity_data)
return upload_pdf_as_label(pdf_buffer, entity, config, mode=mode)
def upload_pdf_as_preview(pdf_buffer, source_entity, config):
"""Upload PDF as preview label file entity"""
return upload_pdf_as_label(pdf_buffer, source_entity, config, mode="Preview")
def upload_pdf_as_label(pdf_buffer, source_entity, config, mode="Preview"):
"""Upload PDF as label file entity with specified mode prefix"""
timestamp = str(int(time.time()))
title_field_value = get_field_or_property_value(source_entity, "title")
if isinstance(title_field_value, list):
entity_title = str(title_field_value[0]) if title_field_value else mode
elif isinstance(title_field_value, dict):
entity_title = str(title_field_value.get("value", mode))
elif title_field_value:
entity_title = str(title_field_value)
else:
entity_title = source_entity.get("title", mode)
filename = f"{mode}_{entity_title}_{timestamp}.pdf"
temp_filename = f"temp_{mode.lower()}_{timestamp}.pdf"
with open(temp_filename, "wb") as f:
f.write(pdf_buffer.read())
try:
file_entity = seal.upload_file(
file_path=temp_filename, file_name=filename, type_title="Label"
)
return file_entity
finally:
if os.path.exists(temp_filename):
os.remove(temp_filename)
def update_manual_labels_preview(entity_id, label_file):
"""Update the Labels field on an entity with the generated label"""
try:
# Get current entity to check if it's editable
entity = seal.get_entity(entity_id=entity_id)
# Make entity editable if it's not already
if entity["status"] != "EDITABLE":
seal.make_entity_editable(entity_id)
# Create reference to the label file
label_ref = {"id": label_file["id"], "version": None}
# Get current Labels field value
current_entity = seal.get_entity(entity_id=entity_id)
current_labels = (
current_entity.get("fields", {}).get("Labels", {}).get("value", [])
)
# Add new label to the list (or create new list if field doesn't exist)
if isinstance(current_labels, list):
updated_labels = current_labels + [label_ref]
else:
updated_labels = [label_ref]
# Update the field
seal.update_field_value_in_entity(
entity_id=entity_id,
field_name="Labels",
field_value=updated_labels,
)
print(f"Successfully updated Labels field with {len(updated_labels)} labels")
except Exception as e:
raise Exception(f"Failed to update Labels field: {str(e)}")
def create_or_update_trigger_label(pdf_buffer, source_entity, config):
"""Create or update trigger label (adapted from fixedGenerationScript.py)"""
source_entity_id = source_entity["id"]
title_field_value = get_field_or_property_value(source_entity, "title")
if isinstance(title_field_value, list):
label_title = str(title_field_value[0]) if title_field_value else "Label"
elif isinstance(title_field_value, dict):
label_title = str(title_field_value.get("value", "Label"))
elif title_field_value:
label_title = str(title_field_value)
else:
label_title = source_entity.get("title", "Label")
timestamp = str(int(time.time()))
temp_filename = f"label_{source_entity_id}_{timestamp}.pdf"
with open(temp_filename, "wb") as f:
f.write(pdf_buffer.read())
try:
search_query = {
"filters": {
"fieldValue": [
{
"name": "Label Source",
"operator": "=",
"value": f"ref({source_entity_id})",
}
]
}
}
existing_labels = seal.search_entities(search_query)
# Use the first matching label by Label Source
existing_label_id = existing_labels[0]["id"] if existing_labels else None
if existing_label_id:
print(f"Updating existing label: {existing_label_id}")
update_existing_label(existing_label_id, temp_filename, label_title, config)
else:
print(f"Creating new label for entity: {source_entity_id}")
create_new_label(temp_filename, label_title, source_entity_id, config)
finally:
if os.path.exists(temp_filename):
os.remove(temp_filename)
def update_existing_label(label_id, temp_filename, label_title, config):
"""Update existing label with new content"""
existing_label = seal.get_entity(entity_id=label_id)
if existing_label["status"] != "EDITABLE":
seal.make_entity_editable(label_id)
timestamp = str(int(time.time()))
unique_filename = f"{label_title}_label_{timestamp}.pdf"
temp_file_entity = seal.upload_file(
file_path=temp_filename, file_name=unique_filename, type_title="Label"
)
new_file_id = temp_file_entity["content"]["value"]["fileId"]
new_content = {"type": "FILE", "value": {"fileId": new_file_id}}
seal._request(
url=f"entities/{label_id}/content",
method="PATCH",
json={"content": new_content},
)
current_title = existing_label.get("title")
if current_title != label_title:
seal.update_entity_title(label_id, label_title)
seal.archive_entity(temp_file_entity["id"], True)
print(f"Label updated successfully: {label_id}")
def create_new_label(temp_filename, label_title, source_entity_id, config):
"""Create new label file entity"""
# Upload the PDF file
file_entity = seal.upload_file(
file_path=temp_filename, file_name=f"{label_title}.pdf", type_title="Label"
)
# Update title
seal.update_entity_title(file_entity["id"], label_title)
# Set reference fields
label_entity_id = file_entity["id"]
# Label Source reference
seal.update_field_value_in_entity(
entity_id=label_entity_id,
field_name="Label Source",
field_value=[{"id": source_entity_id, "version": None}],
)
print(f"New label created successfully: {label_entity_id}")
def update_preview_references(preview_labels, config):
"""Update the Labels reference field in configuration entity"""
if not preview_labels:
return
config_entity_id = config.get("Configuration Entity ID")
if not config_entity_id:
print("No configuration entity ID found, cannot update Labels")
return
preview_refs = [{"id": label["id"], "version": None} for label in preview_labels]
try:
seal.update_field_value_in_entity(
entity_id=config_entity_id,
field_name="Labels",
field_value=preview_refs,
)
print(
f"Updated Labels on configuration entity with {len(preview_refs)} references"
)
except Exception as e:
print(f"Failed to update Labels field: {str(e)}")
def create_label(seal_instance):
global seal
seal = seal_instance
return main()
# Only run main() when script is executed directly, not when imported
if __name__ == "__main__":
main()