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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sl_util/sl_util/secure_regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ def split(pattern, text, maxsplit=0, options=None):

def compile(pattern, options=None):
return re2.compile(pattern, options)


def search(pattern, string, options=None):
return re2.search(pattern, string, options)
20 changes: 19 additions & 1 deletion sl_util/sl_util/str_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import html
import random
import uuid

from word2number import w2n

from sl_util.sl_util import secure_regex as re


def deterministic_uuid(source):
if source:
Expand All @@ -22,5 +26,19 @@ def to_number(input, default_value: int = 0) -> int:
except ValueError:
return default_value


def truncate(s: str, max_length: int) -> str:
return s[:max_length] if s else s
return s[:max_length] if s else s


def remove_html_tags_and_entities(s: str) -> str:
if s is None:
return ''

pattern_tags = re.compile(r'<\s*/?\s*[a-zA-Z]+.*?>')
no_html = re.sub(pattern_tags, ' ', s).strip() if s else s

pattern_spaces = re.compile(r'\s+')
no_spaces = re.sub(pattern_spaces, ' ', no_html) if no_html else no_html

return html.unescape(no_spaces).replace('\xa0', ' ').strip()
5 changes: 5 additions & 0 deletions sl_util/tests/unit/test_secure_regex_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,8 @@ def test_find_all(self, expression, value, expected):
])
def test_split(self, expression, value, expected):
assert sre.findall(expression, value) == expected


def test_search(self):
assert sre.search(r"match\d+.*match\d{1}", "match1 and match2") is not None
assert sre.search(r"match\d+.*match\d{1}", "matchA not found") is None
29 changes: 27 additions & 2 deletions sl_util/tests/unit/test_str_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from pytest import mark, param
import random
from unittest.mock import patch
from sl_util.sl_util.str_utils import deterministic_uuid, to_number

from pytest import mark, param

from sl_util.sl_util.str_utils import deterministic_uuid, to_number, remove_html_tags_and_entities


class TestStrUtils:
Expand Down Expand Up @@ -76,3 +78,26 @@ def test_number_conversions_to_alphanumeric(self, source):
number2 = to_number(source)
# Then we obtain default value 0
assert number1 == number2 == 0

@mark.parametrize('source, expected', [
param('<a href="http://example.com">Link</a>', 'Link', id='only link tag'),
param('<p>This is an <b>AWS</b> component.</p>', 'This is an AWS component.', id='with nested tags'),
param('<div><h1>DDBB</h1> <p>Postgres SQL</p></div>', 'DDBB Postgres SQL', id='with multiple nested tags'),
param('< p>This is an <b >AWS</b > component.< /p > <a href="http://example.com">Link</a>',
'This is an AWS component. Link', id='with tags and link'),
param('<p></p>Void tag', 'Void tag', id='void tag'),
param('IN < http & https', 'IN < http & https', id='with lt and ampersand'),
param('OUT > socket & https', 'OUT > socket & https', id='with gt and ampersand'),
param(' 2 < 3 socket > </3 https> <&udp> <=tcp>', '2 < 3 socket > </3 https> <&udp> <=tcp>', id='with non html gt and lt'),
param('No HTML tags here.', 'No HTML tags here.', id='without html tags'),
param('HTML&nbsp;entities&nbsp;&lt;&gt;&amp;&pound;&euro;&copy;', 'HTML entities <>&£€©', id='with html entities'),
param('', '', id='empty string'),
param(None, '', id='null value')
])
def test_remove_html_tags_and_entities(self, source, expected):
# GIVEN a string with html tags
# WHEN removing html tags
result = remove_html_tags_and_entities(source)

# THEN we obtain the expected string
assert result == expected
2 changes: 1 addition & 1 deletion slp_drawio/slp_drawio/drawio_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class DrawioProcessor(OTMProcessor):
Drawio implementation of OTMProcessor
"""

def __init__(self, project_id: str, project_name: str, source, mappings: [bytes], diag_type=None):
def __init__(self, project_id: str, project_name: str, source, mappings: list[bytes], diag_type=None):
self.project_id = project_id
self.project_name = project_name
self.source: bytes = \
Expand Down
22 changes: 5 additions & 17 deletions slp_drawio/slp_drawio/load/diagram_component_loader.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
from typing import Optional

from otm.otm.entity.representation import RepresentationElement

from slp_drawio.slp_drawio.load.drawio_dict_utils import get_position, get_size, get_mx_cell_components
from slp_drawio.slp_drawio.load.drawio_mxcell_utils import get_cell_name, get_cell_parent_id, get_cell_style
from slp_drawio.slp_drawio.load.stencil_extractors import extract_stencil_type
from slp_drawio.slp_drawio.objects.diagram_objects import DiagramComponent


def _get_shape_parent_id(mx_cell: dict, mx_cell_components: list[dict]):
return mx_cell.get('parent') \
if any(item.get('id') == mx_cell.get('parent') for item in mx_cell_components) else None


def _get_shape_name(mx_cell: dict) -> Optional[str]:
cell_value = mx_cell.get('value') or mx_cell.get('label')
if cell_value:
return cell_value if len(cell_value) > 1 else f'_{cell_value}'
return None


class DiagramComponentLoader:

def __init__(self, project_id: str, source: dict):
Expand All @@ -31,9 +19,9 @@ def load(self) -> list[DiagramComponent]:
for mx_cell in mx_cell_components:
result.append(DiagramComponent(
id=mx_cell.get('id'),
name=_get_shape_name(mx_cell),
name=get_cell_name(mx_cell),
shape_type=extract_stencil_type(mx_cell),
shape_parent_id=_get_shape_parent_id(mx_cell, mx_cell_components),
shape_parent_id=get_cell_parent_id(mx_cell, mx_cell_components),
representations=[self._get_representation_element(mx_cell)]
))

Expand All @@ -46,5 +34,5 @@ def _get_representation_element(self, mx_cell: dict) -> RepresentationElement:
representation=f"{self._project_id}-diagram",
position=get_position(mx_cell),
size=get_size(mx_cell),
attributes={'style': mx_cell.get('style')}
attributes={'style': get_cell_style(mx_cell)}
)
30 changes: 30 additions & 0 deletions slp_drawio/slp_drawio/load/drawio_mxcell_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional

from sl_util.sl_util.str_utils import remove_html_tags_and_entities
from slp_drawio.slp_drawio.parse.drawio_styles_from_html_tags_parser import DrawioStylesFromHtmlTagsParser


def get_cell_style(mx_cell: dict) -> str:
cell_value = mx_cell.get('value') or mx_cell.get('label')
return str(mx_cell.get('style')) + _extract_css_from_cell_value(cell_value)


def get_cell_parent_id(mx_cell: dict, mx_cell_components: list[dict]):
return mx_cell.get('parent') \
if any(item.get('id') == mx_cell.get('parent') for item in mx_cell_components) else None


def get_cell_name(mx_cell: dict) -> Optional[str]:
cell_value = mx_cell.get('value') or mx_cell.get('label')
if cell_value:
cell_value = remove_html_tags_and_entities(cell_value).strip()
return cell_value if len(cell_value) > 1 else f'_{cell_value}'
return None


def _extract_css_from_cell_value(html: Optional[str]) -> str:
if not html:
return ""
parser = DrawioStylesFromHtmlTagsParser()
css_str = ";".join(parser.parse(html))
return f"{css_str};" if css_str else ""
73 changes: 73 additions & 0 deletions slp_drawio/slp_drawio/parse/drawio_styles_from_html_tags_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from html.parser import HTMLParser

DRAWIO_FONT_STYLE_KEY = 'fontStyle'
DRAWIO_FONT_COLOR_KEY = 'fontColor'
DRAWIO_FONT_FAMILY_KEY = 'fontFamily'
DRAWIO_FONT_SIZE_KEY = 'fontSize'



def _sum_drawio_font_styles(styles):
font_style_sum = 0
result = []

for item in styles:
key, value = item.split('=', 1)
key = key.strip()
value = value.strip()

if key == DRAWIO_FONT_STYLE_KEY:
font_style_sum += int(value)
else:
result.append(f'{key}={value}')

if font_style_sum:
result.insert(0, f'{DRAWIO_FONT_STYLE_KEY}={font_style_sum}')

return result



class DrawioStylesFromHtmlTagsParser(HTMLParser):


def __init__(self):
super().__init__()
self.styles = []

def parse(self, html: str) -> list[str]:
"""
Parses the given HTML string and extracts Drawio-compatible styles.
:param html: The HTML string to parse.
:return: A list of Drawio-compatible style strings.
"""
self.styles = []
self.feed(html)
return _sum_drawio_font_styles(self.styles)

def handle_starttag(self, tag, attrs):
"""
Handles an HTML tag and extracts styles.

Drawio uses specific CSS styles for formatting:
Style fontStyle
Bold 1
Italic 2
Underline 4
Strikethrough 8
All of them combined: sum of values (e.g., Bold + Italic + Underline + Strikethrough = 15)
"""
if tag == "b":
self.styles.append(f"{DRAWIO_FONT_STYLE_KEY}= 1")
elif tag == "i":
self.styles.append(f"{DRAWIO_FONT_STYLE_KEY}= 2")
elif tag == "u":
self.styles.append(f"{DRAWIO_FONT_STYLE_KEY}= 4")
elif tag == "strike" or tag == "s":
self.styles.append(f"{DRAWIO_FONT_STYLE_KEY}= 8")
elif tag == "font":
attr_dict = dict(attrs)
if "color" in attr_dict:
self.styles.append(f"{DRAWIO_FONT_COLOR_KEY}= {attr_dict['color']}")
if "face" in attr_dict:
self.styles.append(f"{DRAWIO_FONT_FAMILY_KEY}= {attr_dict['face']}")
57 changes: 57 additions & 0 deletions slp_drawio/tests/integration/test_drawio_processor.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import pytest
from pytest import mark, param

from sl_util.sl_util import secure_regex as re
from sl_util.sl_util.file_utils import get_byte_data
from sl_util.tests.util.file_utils import generate_temporary_file
from slp_base import MappingFileNotValidError
from slp_base.slp_base.errors import ErrorCode
from slp_base.slp_base.mapping import MAX_SIZE as MAPPING_MAX_SIZE, MIN_SIZE as MAPPING_MIN_SIZE
from slp_drawio import DrawioProcessor
from slp_drawio.tests.resources import test_resource_paths
from slp_drawio.tests.resources.test_resource_paths import shape_names_with_html, default_drawio_mapping

SAMPLE_ID = 'id'
SAMPLE_NAME = 'name'
SAMPLE_VALID_DRAWIO_PATH = test_resource_paths.aws_minimal_xml
DEFAULT_MAPPING_FILE = get_byte_data(test_resource_paths.default_drawio_mapping)


class TestDrawioProcessor:
@mark.parametrize('mappings', [
param([generate_temporary_file(MAPPING_MIN_SIZE - 1), DEFAULT_MAPPING_FILE], id='mapping file too small'),
Expand All @@ -37,3 +40,57 @@ def test_invalid_mapping_size(self, mappings: list[bytes]):
assert 'Mapping files are not valid' == error.value.title
assert 'Mapping files are not valid. Invalid size' == error.value.detail
assert 'Mapping files are not valid. Invalid size' == error.value.message

@pytest.mark.parametrize('filepath', [
pytest.param(shape_names_with_html, id='aws_with_html'),
])
def test_handle_html_shape_names(self, filepath: str):
# GIVEN the valid file
file = open(filepath, 'rb')
# AND the default mapping
default_drawio_mapping_file = get_byte_data(default_drawio_mapping)

# AND the processor
processor = DrawioProcessor('html_names', 'HTML Names', file, [default_drawio_mapping_file])

# WHEN we process the file
result = processor.process()

# THEN the component names are correctly parsed
components = result.components
components.sort(key=lambda c: c.name)
assert len(components) == 10
assert components[0].name == 'Bold EC2'
assert components[1].name == 'Combined EC2'
assert components[2].name == 'Courier EC2'
assert components[3].name == 'Drawio example with Cell names with HTML'
assert components[4].name == 'Font size 16 EC2'
assert components[5].name == 'Italic EC2'
assert components[6].name == 'Non HTML EC2'
assert components[7].name == 'Red EC2'
assert components[8].name == 'Strikethrough EC2'
assert components[9].name == 'Underline EC2'

# AND the representation attributes has the style from the html original name
assert 'fontStyle=1;' in components[0].representations[0].attributes['style']
c1 = components[1].representations[0].attributes['style']
assert _validate_font_styles(c1, '0', '15')
assert 'fontFamily=Courier New;' in c1
assert 'fontColor=#ff0000;' in c1
assert 'fontFamily=Courier New;' in components[2].representations[0].attributes['style']
assert 'fontSize=16;' in components[4].representations[0].attributes['style']
c5 = components[5].representations[0].attributes['style']
assert _validate_font_styles(c5, '0', '2')
assert 'fontStyle=0;' in components[6].representations[0].attributes['style']
assert 'fontColor=#ff0000;' in components[7].representations[0].attributes['style']
assert _validate_font_styles(components[8].representations[0].attributes['style'], '0', '8')
assert _validate_font_styles(components[9].representations[0].attributes['style'], '0', '4')


def _validate_font_styles(style: str, value1: str, value2: str) -> bool:
"""
Returns true if in the given style string there are exactly two fontStyle
definitions (value1 then value2), with none before, between, or after.
"""
m = re.search(fr"(.*)fontStyle\s*=\s*{value1}(.*)?fontStyle\s*=\s*{value2}(.*)", style)
return m and "fontStyle" not in m.group(1) and "fontStyle" not in m.group(2) and "fontStyle" not in m.group(3)
Loading