diff --git a/lib/src/actions/actions.dart b/lib/src/actions/actions.dart index 87530deb5..595719924 100644 --- a/lib/src/actions/actions.dart +++ b/lib/src/actions/actions.dart @@ -2153,6 +2153,27 @@ abstract class ExportSvg with BuiltJsonSerializable implements Action, Built { + bool get export_svg_text_separately; + + /************************ begin BuiltValue boilerplate ************************/ + factory ExportSvgTextSeparatelySet(bool export_svg_text_separately) => + ExportSvgTextSeparatelySet.from((b) => b..export_svg_text_separately = export_svg_text_separately); + + /************************ begin BuiltValue boilerplate ************************/ + factory ExportSvgTextSeparatelySet.from([void Function(ExportSvgTextSeparatelySetBuilder) updates]) = + _$ExportSvgTextSeparatelySet; + + ExportSvgTextSeparatelySet._(); + + static Serializer get serializer => _$exportSvgTextSeparatelySetSerializer; +} + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // Strand part action diff --git a/lib/src/middleware/export_svg.dart b/lib/src/middleware/export_svg.dart index 9515d09c2..bd8297e28 100644 --- a/lib/src/middleware/export_svg.dart +++ b/lib/src/middleware/export_svg.dart @@ -1,15 +1,14 @@ import 'dart:html'; import 'dart:svg' as svg; import 'dart:svg'; +import 'dart:math' as math; import 'package:built_collection/built_collection.dart'; import 'package:over_react/over_react.dart'; -import 'package:react/react_client/react_interop.dart'; import 'package:redux/redux.dart'; import 'package:scadnano/src/middleware/system_clipboard.dart'; -import 'package:scadnano/src/state/domain.dart'; import 'package:scadnano/src/state/strand.dart'; -import 'package:scadnano/src/view/design_main_base_pair_lines.dart'; +import 'package:scadnano/src/view/design_main_dna_sequence.dart'; import '../app.dart'; import '../state/app_state.dart'; @@ -51,11 +50,16 @@ export_svg_middleware(Store store, dynamic action, NextDispatcher next window.alert("No strands are selected, so there is nothing to export.\n" "Please select some strands before choosing this option."); } else { - var cloned_svg_element_with_style = get_cloned_svg_element_with_style(selected_elts); + var cloned_svg_element_with_style = get_cloned_svg_element_with_style( + selected_elts, store.state.ui_state.export_svg_text_separately); _export_from_element(cloned_svg_element_with_style, 'selected'); } - } else + } else { + if (store.state.ui_state.export_svg_text_separately) { + elt = get_cloned_svg_element_with_style([elt], store.state.ui_state.export_svg_text_separately); + } _export_from_element(elt, 'main'); + } } if (action.type == actions.ExportSvgType.side || action.type == actions.ExportSvgType.both) { var elt = document.getElementById("side-view-svg"); @@ -107,9 +111,154 @@ List get_svg_elements_of_base_pairs(BuiltMap> base_ return elts; } -SvgSvgElement get_cloned_svg_element_with_style(List selected_elts) { +List rotate_vector(List vec, double ang) { + ang = ang * (math.pi / 180); + var cos = math.cos(ang); + var sin = math.sin(ang); + return [vec[0] * cos - vec[1] * sin, vec[0] * sin + vec[1] * cos]; +} + +// gets the height of a character in font in px +double get_text_height(String font) { + CanvasElement element = document.createElement("canvas"); + CanvasRenderingContext2D context = element.getContext("2d"); + context.font = font; + return double.tryParse(context.font.replaceAll(RegExp(r'[^0-9\.]'), '')); +} + +// returns a matrix that represents the change made by dominant-baseline css property +DomMatrix dominant_baseline_matrix(String dominant_baseline, double rot, String font) { + switch (dominant_baseline) { + case "ideographic": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (-3 * get_text_height(font)) / 12], rot) + ]); + case "hanging": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (9 * get_text_height(font)) / 12], rot) + ]); + case "central": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (4 * get_text_height(font)) / 12], rot) + ]); + default: + return new DomMatrix([1, 0, 0, 1, 0, 0]); + } +} + +Map matrix_to_map(Matrix matrix) { + return { + "a": matrix.a, + "b": matrix.b, + "c": matrix.c, + "d": matrix.d, + "e": matrix.e, + "f": matrix.f, + }; +} + +Map dom_matrix_to_map(DomMatrix matrix) { + return { + "a": matrix.a, + "b": matrix.b, + "c": matrix.c, + "d": matrix.d, + "e": matrix.e, + "f": matrix.f, + }; +} + +Map point_to_map(svg.Point point) { + return {"x": point.x, "y": point.y}; +} + +// creates a new separate text svg for the jth character on a svg text element +TextElement create_portable_element(TextContentElement text_ele, int j) { + TextElement char_ele = document.createElementNS("http://www.w3.org/2000/svg", "text"); + char_ele.text = text_ele.text[j]; + char_ele.setAttribute("style", text_ele.style.cssText); + var pos = DomPoint.fromPoint(point_to_map(text_ele.getStartPositionOfChar(j))); + var rot = text_ele.getRotationOfChar(j); + + for (int i = 0; i < text_ele.transform.baseVal.numberOfItems; ++i) { + var item = text_ele.transform.baseVal.getItem(i); + pos = pos.matrixTransform(matrix_to_map(item.matrix)); + rot = item.angle; + } + if (char_ele.style.getPropertyValue("dominant-baseline") != "") { + pos = pos.matrixTransform(dom_matrix_to_map(dominant_baseline_matrix( + char_ele.style.getPropertyValue("dominant-baseline"), + rot, + text_ele.style.fontSize + " " + text_ele.style.fontFamily))); + } + char_ele.style.setProperty("dominant-baseline", ""); + char_ele.style.setProperty("text-anchor", "start"); + if (text_ele.classes.any([ + "loopout-extension-length", + "dna-seq-insertion", + "dna-seq-loopout", + "dna-seq-extension", + "dna-seq" + ].contains)) { + char_ele.style.setProperty( + "text-shadow", // doesn't work in PowerPoint + "-0.7px -0.7px 0 #fff, 0.7px -0.7px 0 #fff, -0.7px 0.7px 0 #fff, 0.7px 0.7px 0 #fff"); + } + char_ele.setAttribute("x", pos.x.toString()); + char_ele.setAttribute("y", pos.y.toString()); + char_ele.setAttribute("transform", "rotate(${rot} ${pos.x} ${pos.y})"); + return char_ele; +} + +// makes a svg compatible for PowerPoint +Element make_portable(Element src) { + var src_children = src.querySelectorAll("*"); + document.body.append(src); + for (int i = 0; i < src_children.length; ++i) { + if (src_children[i] is svg.TextContentElement) { + TextContentElement text_ele = src_children[i] as TextContentElement; + if (text_ele.children.length == 1 && text_ele.children[0].tagName == "textPath") { + continue; + } + List portable_eles = []; + for (int j = 0; j < text_ele.getNumberOfChars(); ++j) { + var char_ele = create_portable_element(text_ele, j); + portable_eles.add(char_ele); + } + if (text_ele is TextPathElement) { + // move TextPath children up and delete the TextPath + var parent = text_ele.parent; + var new_parent = document.createElementNS("http://www.w3.org/2000/svg", "g"); + parent.parent.append(new_parent); + new_parent.append(text_ele); + parent.remove(); + } + portable_eles.forEach((v) => text_ele.parentNode.append(v)); + text_ele.remove(); + } + } + src.remove(); + return src; +} + +SvgSvgElement get_cloned_svg_element_with_style(List selected_elts, bool separate_text) { var cloned_svg_element_with_style = SvgSvgElement() ..children = selected_elts.map(clone_and_apply_style).toList(); + if (separate_text) { + cloned_svg_element_with_style = make_portable(cloned_svg_element_with_style); + } // we can't get bbox without it being added to the DOM first document.body.append(cloned_svg_element_with_style); @@ -118,7 +267,7 @@ SvgSvgElement get_cloned_svg_element_with_style(List selected_elts) { // have to add some padding to viewbox, for some reason bbox doesn't always fit it by a few pixels?? cloned_svg_element_with_style.setAttribute('viewBox', - '${bbox.x.floor() - 1} ${bbox.y.floor() - 1} ${bbox.width.ceil() + 3} ${bbox.height.ceil() + 3}'); + '${bbox.x.floor() - 1} ${bbox.y.floor() - 1} ${bbox.width.ceil() + 3} ${bbox.height.ceil() + 6}'); return cloned_svg_element_with_style; } @@ -151,7 +300,7 @@ _export_svg(svg.SvgSvgElement svg_element, String filename_append) { } _copy_from_elements(List svg_elements) { - var cloned_svg_element_with_style = get_cloned_svg_element_with_style(svg_elements); + var cloned_svg_element_with_style = get_cloned_svg_element_with_style(svg_elements, false); util.copy_svg_as_png(cloned_svg_element_with_style); } @@ -202,12 +351,12 @@ final relevant_styles = { Element clone_and_apply_style(Element elt_orig) { Element elt_styled = elt_orig.clone(true); - bool selected = elt_orig.classes.contains('selected'); + bool selected = elt_orig.classes.contains('selected-pink'); - elt_orig.classes.remove('selected'); + elt_orig.classes.remove('selected-pink'); clone_and_apply_style_rec(elt_styled, elt_orig); - if (selected) elt_orig.classes.add('selected'); + if (selected) elt_orig.classes.add('selected-pink'); // need to get from original since it has been rendered (styled hasn't been rendered so has 0 bounding box // also need to get from g element, not svg element, since svg element dimensions based on original @@ -223,6 +372,7 @@ Element clone_and_apply_style(Element elt_orig) { } clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) { + // print('elt_styled ${elt_styled.id} and elt_orig ${elt_orig.id}'); // Set children_styled_to_remove = {}; var tag_name = elt_styled.tagName; @@ -235,7 +385,8 @@ clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) if (relevant_styles.keys.contains(tag_name)) { var style_def = elt_orig.getComputedStyle(); - + // print( + // 'id ${elt_styled.id} fill ${style_def.getPropertyValue("fill")} relevant_styles ${relevant_styles[tag_name]}'); //TODO: figure out how to remove nodes that aren't visible; // getting error "Unsupported operation: Cannot setRange on filtered list" when removing children // if (style_def.visibility == 'hidden') { @@ -249,14 +400,17 @@ clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) // correcting for this bug in InkScape that causes it to render hidden SVG objects: // https://bugs.launchpad.net/inkscape/+bug/1577763 if (style_name == 'visibility' && style_value == 'hidden') { - style_strings.add('display: none'); + // style_strings.add('display: none'); + elt_styled.style.setProperty('display', 'none'); } - style_strings.add('${style_name}: ${style_value}'); + elt_styled.style.setProperty(style_name, style_value); + // style_strings.add('${style_name}: ${style_value};'); } } - var style_string = style_strings.join('; ') + ';'; + // var style_string = style_strings.join(' '); - elt_styled.setAttribute("style", style_string); + // elt_styled.setAttribute("style", style_string); + // print(elt_styled.styleMap); // print('${' ' * depth * 2} ${tag_name} ${elt_orig.classes.toList()} style: $style_string'); } diff --git a/lib/src/reducers/app_ui_state_reducer.dart b/lib/src/reducers/app_ui_state_reducer.dart index 88d1add21..6bd4442d7 100644 --- a/lib/src/reducers/app_ui_state_reducer.dart +++ b/lib/src/reducers/app_ui_state_reducer.dart @@ -236,6 +236,9 @@ bool show_base_pair_lines_with_mismatches_reducer( bool _, actions.ShowBasePairLinesWithMismatchesSet action) => action.show_base_pair_lines_with_mismatches; +bool export_svg_text_separately_reducer(bool _, actions.ExportSvgTextSeparatelySet action) => + action.export_svg_text_separately; + bool display_major_tick_widths_reducer(bool _, actions.SetDisplayMajorTickWidths action) => action.show; bool strand_paste_keep_color_reducer(bool _, actions.StrandPasteKeepColorSet action) => action.keep; @@ -450,6 +453,7 @@ AppUIStateStorables app_ui_state_storable_local_reducer(AppUIStateStorables stor ..display_major_tick_widths_all_helices = TypedReducer(display_major_tick_widths_all_helices_reducer)(storables.display_major_tick_widths_all_helices, action) ..show_base_pair_lines = TypedReducer(show_base_pair_lines_reducer)(storables.show_base_pair_lines, action) ..show_base_pair_lines_with_mismatches = TypedReducer(show_base_pair_lines_with_mismatches_reducer)(storables.show_base_pair_lines_with_mismatches, action) + ..export_svg_text_separately = TypedReducer(export_svg_text_separately_reducer)(storables.export_svg_text_separately, action) ..only_display_selected_helices = TypedReducer(only_display_selected_helices_reducer)(storables.only_display_selected_helices, action) ..default_crossover_type_scaffold_for_setting_helix_rolls = TypedReducer(default_crossover_type_scaffold_for_setting_helix_rolls_reducer)(storables.default_crossover_type_scaffold_for_setting_helix_rolls, action) ..default_crossover_type_staple_for_setting_helix_rolls = TypedReducer(default_crossover_type_staple_for_setting_helix_rolls_reducer)(storables.default_crossover_type_staple_for_setting_helix_rolls, action) diff --git a/lib/src/serializers.dart b/lib/src/serializers.dart index 2857f7d6c..fff3cc414 100644 --- a/lib/src/serializers.dart +++ b/lib/src/serializers.dart @@ -123,6 +123,7 @@ part 'serializers.g.dart'; ShowModificationsSet, ShowMismatchesSet, SetShowEditor, + ExportSvgTextSeparatelySet, SaveDNAFile, PrepareToLoadDNAFile, LoadDNAFile, diff --git a/lib/src/state/app_ui_state.dart b/lib/src/state/app_ui_state.dart index 660e45299..38a9c4a0d 100644 --- a/lib/src/state/app_ui_state.dart +++ b/lib/src/state/app_ui_state.dart @@ -214,6 +214,8 @@ abstract class AppUIState with BuiltJsonSerializable implements Built storables.show_loopout_extension_length; + bool get export_svg_text_separately => storables.export_svg_text_separately; + bool get default_crossover_type_scaffold_for_setting_helix_rolls => storables.default_crossover_type_scaffold_for_setting_helix_rolls; diff --git a/lib/src/state/app_ui_state_storables.dart b/lib/src/state/app_ui_state_storables.dart index fc68fbf0f..b83fd76ec 100644 --- a/lib/src/state/app_ui_state_storables.dart +++ b/lib/src/state/app_ui_state_storables.dart @@ -125,6 +125,8 @@ abstract class AppUIStateStorables bool get selection_box_intersection; + bool get export_svg_text_separately; + static void _initializeBuilder(AppUIStateStorablesBuilder b) { // This ensures that even if these keys are not in localStorage (e.g., due to upgrading), // then they will be populated with a default value instead of raising an exception. @@ -181,6 +183,7 @@ abstract class AppUIStateStorables b.clear_helix_selection_when_loading_new_design = false; b.show_mouseover_data = false; b.selection_box_intersection = false; + b.export_svg_text_separately = false; } /************************ begin BuiltValue boilerplate ************************/ diff --git a/lib/src/util.dart b/lib/src/util.dart index 4100f1c96..bfa87ed68 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -1053,7 +1053,7 @@ num current_zoom(bool is_main) => is_main ? current_zoom_main_js() : current_zoo CssStyleSheet get_scadnano_stylesheet() { for (var stylesheet in document.styleSheets) { - if (stylesheet.href.contains(constants.scadnano_css_stylesheet_name)) { + if (stylesheet.href != null && stylesheet.href.contains(constants.scadnano_css_stylesheet_name)) { return stylesheet; } } diff --git a/lib/src/view/design_main_dna_sequence.dart b/lib/src/view/design_main_dna_sequence.dart index d5b8cce35..1c4585e17 100644 --- a/lib/src/view/design_main_dna_sequence.dart +++ b/lib/src/view/design_main_dna_sequence.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:over_react/over_react.dart'; import 'package:built_collection/built_collection.dart'; import 'package:platform_detect/platform_detect.dart'; +import 'package:react/react_client/react_interop.dart'; import 'package:scadnano/src/state/group.dart'; import 'package:scadnano/src/view/transform_by_helix_group.dart'; import 'package:tuple/tuple.dart'; @@ -91,6 +92,7 @@ class DesignMainDNASequenceComponent extends UiComponent2 ConnectedMenu = connect( ..show_grid_coordinates_side_view = state.ui_state.show_grid_coordinates_side_view ..show_helices_axis_arrows = state.ui_state.show_helices_axis_arrows ..show_loopout_extension_length = state.ui_state.show_loopout_extension_length + ..export_svg_text_separately = state.ui_state.export_svg_text_separately ..show_slice_bar = state.ui_state.show_slice_bar ..show_mouseover_data = state.ui_state.show_mouseover_data ..disable_png_caching_dna_sequences = state.ui_state.disable_png_caching_dna_sequences @@ -156,6 +157,7 @@ mixin MenuPropsMixin on UiProps { bool display_reverse_DNA_right_side_up; bool default_crossover_type_scaffold_for_setting_helix_rolls; bool default_crossover_type_staple_for_setting_helix_rolls; + bool export_svg_text_separately; LocalStorageDesignChoice local_storage_design_choice; bool clear_helix_selection_when_loading_new_design; bool show_slice_bar; @@ -1180,6 +1182,19 @@ A highlighting effect will still appear. ..on_click = ((_) => props.dispatch(actions.ExportSvg(type: actions.ExportSvgType.selected))) ..tooltip = "Export SVG figure of selected strands" ..display = 'SVG of selected strands')(), + (MenuBoolean() + ..value = props.export_svg_text_separately + ..display = 'export svg text separately (PPT)' + ..tooltip = '''\ +When selected, every symbol of the text in a DNA sequence is exported as a separate +SVG text element. This is useful if the SVG will be imported into Powerpoint, which +is less expressive than SVG and can render the text strangely.''' + ..name = 'export-svg-text-separately' + ..onChange = (_) { + props.dispatch(actions.ExportSvgTextSeparatelySet(!props.export_svg_text_separately)); + } + ..key = 'export-svg-text-separately')(), + DropdownDivider({'key': 'divider-export-svg'}), (MenuDropdownItem() ..on_click = ((_) => app.disable_keyboard_shortcuts_while(export_dna_sequences.export_dna)) ..tooltip = "Export DNA sequences of strands to a file." @@ -1189,7 +1204,7 @@ A highlighting effect will still appear. ..tooltip = "Export design's DNA sequences as a CSV in the same way as cadnano v2.\n" "This is useful, for example, with CanDo's atomic model generator." ..display = 'DNA sequences (cadnano v2 format)')(), - DropdownDivider({'key': 'divider-not-full-design'}), + DropdownDivider({'key': 'divider-export-dna'}), (MenuDropdownItem() ..on_click = ((_) => props.dispatch(actions.ExportCadnanoFile(whitespace: true))) ..tooltip = "Export design to cadnano (version 2) .json file." @@ -1216,6 +1231,7 @@ cadnano files that have whitespace. ("Bad .json file format is detected in 'structure.json'. Or no dsDNA or strand crossovers exist.")""" ..display = 'cadnano v2 no whitespace' ..key = 'export-cadnano-no-whitespace')(), + DropdownDivider({'key': 'divider-cadnano'}), (MenuDropdownItem() ..on_click = ((_) => props.dispatch(actions.OxdnaExport())) ..tooltip = "Export design to oxDNA .dat and .top files, which can be loaded in oxDNA or oxView."